mirror of
https://github.com/actualbudget/actual.git
synced 2026-10-11 22:49:59 +00:00
* [AI] Migrate desktop-client to subpath imports Replace the `@desktop-client/*` path alias with Node.js subpath imports (`#*`) across packages/desktop-client: - Declare the full `imports` map in packages/desktop-client/package.json (bare index entries, root-level files, and per-subdirectory wildcards with explicit extension overrides where `.ts` and `.tsx` mix). - Update all source files to import from `#...` specifiers. - Drop the `@desktop-client` group from .oxfmtrc.json. - Enable `actual/prefer-subpath-imports` for desktop-client in .oxlintrc.json so future code keeps using the subpath form. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * [AI] Drop legacy desktop-client aliases Remove the `@desktop-client/*` and `loot-core/*` path aliases from vite.config.ts and tsconfig.json now that every desktop-client source file imports via subpath imports / `@actual-app/core`. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * Release notes * [AI] Use electron-renderer condition for renderer-only exports Desktop-client's Vite build used the `electron` resolve condition, which overlapped with loot-core exports where `electron` means the Node/main variant (e.g. `shared/platform.electron.ts` using `os`, `platform/server/asyncStorage/index.electron.ts` using `fs`). Once the `loot-core` Vite alias was removed, the renderer bundle started pulling those Node variants and crashed at runtime with `It.default.platform is not a function` inside `platform.electron.ts`. Introduce a distinct `electron-renderer` condition used only by desktop-client's Vite config, and rename the `electron` key to `electron-renderer` on the sole loot-core export whose `electron` branch is the Electron renderer variant (`#/./platform/client/connection`, the IPC `global.Actual.ipcConnect` file). Every other `electron`-conditioned export keeps its Node semantics and is still matched by loot-core's own `vite.desktop.config.ts` (`conditions: ['electron']`). Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * [AI] Drop .electron.* extensions from loot-core desktop resolver Now that every Node/main variant is selected via the `electron` subpath import condition in `packages/loot-core/package.json`, Vite's `resolveExtensions` list no longer needs the `.electron.js`, `.electron.ts`, `.electron.tsx` entries. Remove them to keep resolution explicit and avoid implicit extension picking. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * [AI] Align desktop-client TS resolution with Vite - Set `customConditions: ["electron-renderer"]` in `packages/desktop-client/tsconfig.json` so TypeScript resolves conditional imports (notably `@actual-app/core/platform/client/connection`) to the same file Vite picks at runtime. Today the surfaces happen to match because both variants import from a shared `index-types.ts`, but the alignment prevents a latent drift bug. - Fix typo in the release note (`Standartise` -> `Standardise`). Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
388 lines
10 KiB
TypeScript
388 lines
10 KiB
TypeScript
// @ts-strict-ignore
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import type { CSSProperties } from 'react';
|
|
import { Trans, useTranslation } from 'react-i18next';
|
|
|
|
import { Button } from '@actual-app/components/button';
|
|
import { SpaceBetween } from '@actual-app/components/space-between';
|
|
import { styles } from '@actual-app/components/styles';
|
|
import { Text } from '@actual-app/components/text';
|
|
import { theme } from '@actual-app/components/theme';
|
|
import { View } from '@actual-app/components/view';
|
|
import { send } from '@actual-app/core/platform/client/connection';
|
|
import * as undo from '@actual-app/core/platform/client/undo';
|
|
import type { NewUserEntity, UserEntity } from '@actual-app/core/types/models';
|
|
|
|
import { InfiniteScrollWrapper } from '#components/common/InfiniteScrollWrapper';
|
|
import { Link } from '#components/common/Link';
|
|
import { Search } from '#components/common/Search';
|
|
import { SelectedProvider, useSelected } from '#hooks/useSelected';
|
|
import { pushModal } from '#modals/modalsSlice';
|
|
import { addNotification } from '#notifications/notificationsSlice';
|
|
import { useDispatch } from '#redux';
|
|
import { signOut } from '#users/usersSlice';
|
|
|
|
import { UserDirectoryHeader } from './UserDirectoryHeader';
|
|
import { UserDirectoryRow } from './UserDirectoryRow';
|
|
|
|
type ManageUserDirectoryContentProps = {
|
|
isModal: boolean;
|
|
};
|
|
|
|
function useGetUserDirectoryErrors() {
|
|
const { t } = useTranslation();
|
|
|
|
const getUserDirectoryErrors = useCallback(
|
|
reason => {
|
|
switch (reason) {
|
|
case 'unauthorized':
|
|
return t('You are not logged in.');
|
|
case 'token-expired':
|
|
return t('Login expired, please log in 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 exist, 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 },
|
|
);
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
return { getUserDirectoryErrors };
|
|
}
|
|
|
|
function UserDirectoryContent({ isModal }: ManageUserDirectoryContentProps) {
|
|
const { t } = useTranslation();
|
|
|
|
const [allUsers, setAllUsers] = useState([]);
|
|
const [page, setPage] = useState(0);
|
|
const [filter, setFilter] = useState('');
|
|
const dispatch = useDispatch();
|
|
|
|
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 selectedCount = selectedInst.items.size;
|
|
|
|
const [hoveredUser, setHoveredUser] = useState(null);
|
|
|
|
const onSearchChange = useCallback(
|
|
(value: string) => {
|
|
setFilter(value);
|
|
setPage(0);
|
|
},
|
|
[setFilter],
|
|
);
|
|
|
|
const loadUsers = useCallback(async () => {
|
|
const loadedUsers = (await send('users-get')) ?? [];
|
|
if ('error' in loadedUsers) {
|
|
dispatch(
|
|
addNotification({
|
|
notification: {
|
|
type: 'error',
|
|
id: 'error',
|
|
title: t('Error getting users'),
|
|
sticky: true,
|
|
message: getUserDirectoryErrors(loadedUsers.error),
|
|
},
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
setAllUsers(loadedUsers);
|
|
return loadedUsers;
|
|
}, [dispatch, getUserDirectoryErrors, t]);
|
|
|
|
useEffect(() => {
|
|
async function loadData() {
|
|
await loadUsers();
|
|
}
|
|
|
|
void loadData();
|
|
|
|
return () => {
|
|
undo.setUndoState('openModal', null);
|
|
};
|
|
}, [loadUsers]);
|
|
|
|
function loadMore() {
|
|
setPage(page => page + 1);
|
|
}
|
|
|
|
const onDeleteSelected = useCallback(async () => {
|
|
const res = await send('user-delete-all', [...selectedInst.items]);
|
|
|
|
const error = res['error'];
|
|
const someDeletionsFailed = res['someDeletionsFailed'];
|
|
if (error || someDeletionsFailed) {
|
|
if (error === 'token-expired') {
|
|
dispatch(
|
|
addNotification({
|
|
notification: {
|
|
type: 'error',
|
|
id: 'login-expired',
|
|
title: t('Login expired'),
|
|
sticky: true,
|
|
message: getUserDirectoryErrors(error),
|
|
button: {
|
|
title: t('Go to login'),
|
|
action: () => {
|
|
void dispatch(signOut());
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
} else {
|
|
dispatch(
|
|
addNotification({
|
|
notification: {
|
|
type: 'error',
|
|
title: t('Something happened while deleting users'),
|
|
sticky: true,
|
|
message: getUserDirectoryErrors(error),
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
await loadUsers();
|
|
selectedInst.dispatch({ type: 'select-none' });
|
|
}, [selectedInst, loadUsers, dispatch, t, getUserDirectoryErrors]);
|
|
|
|
const onEditUser = useCallback(
|
|
user => {
|
|
dispatch(
|
|
pushModal({
|
|
modal: {
|
|
name: 'edit-user',
|
|
options: {
|
|
user,
|
|
onSave: async () => {
|
|
await loadUsers();
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
},
|
|
[dispatch, loadUsers],
|
|
);
|
|
|
|
function onAddUser() {
|
|
const user: NewUserEntity = {
|
|
userName: '',
|
|
role: null,
|
|
enabled: true,
|
|
displayName: '',
|
|
};
|
|
|
|
dispatch(
|
|
pushModal({
|
|
modal: {
|
|
name: 'edit-user',
|
|
options: {
|
|
user,
|
|
onSave: async () => {
|
|
await loadUsers();
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
const onHover = useCallback(id => {
|
|
setHoveredUser(id);
|
|
}, []);
|
|
|
|
return (
|
|
<SelectedProvider instance={selectedInst}>
|
|
<View>
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
padding: isModal ? '0 13px 15px' : '0 0 15px',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
color: theme.pageTextLight,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
width: '50%',
|
|
}}
|
|
>
|
|
<Text>
|
|
<Trans>
|
|
Manage and view users who can create new budgets or be invited
|
|
to access existing ones.
|
|
</Trans>{' '}
|
|
<Link
|
|
variant="external"
|
|
to="https://actualbudget.org/docs/config/multi-user/"
|
|
linkColor="muted"
|
|
>
|
|
<Trans>Learn more</Trans>
|
|
</Link>
|
|
</Text>
|
|
</View>
|
|
<View style={{ flex: 1 }} />
|
|
<Search
|
|
placeholder={t('Filter users...')}
|
|
value={filter}
|
|
onChange={onSearchChange}
|
|
/>
|
|
</View>
|
|
|
|
<View style={styles.tableContainer}>
|
|
<UserDirectoryHeader />
|
|
<InfiniteScrollWrapper loadMore={loadMore}>
|
|
{filteredUsers.length === 0 ? (
|
|
<EmptyMessage text={t('No users')} style={{ marginTop: 15 }} />
|
|
) : (
|
|
<UsersList
|
|
users={filteredUsers}
|
|
selectedItems={selectedInst.items}
|
|
hoveredUser={hoveredUser}
|
|
onHover={onHover}
|
|
onEditUser={onEditUser}
|
|
/>
|
|
)}
|
|
</InfiniteScrollWrapper>
|
|
</View>
|
|
<View
|
|
style={{
|
|
paddingBlock: 15,
|
|
paddingInline: isModal ? 13 : 0,
|
|
borderTop: isModal && '1px solid ' + theme.pillBorder,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<SpaceBetween
|
|
gap={10}
|
|
style={{ alignItems: 'center', justifyContent: 'flex-end' }}
|
|
>
|
|
{selectedInst.items.size > 0 && (
|
|
<Button onPress={onDeleteSelected}>
|
|
<Trans count={selectedCount}>
|
|
Delete {{ selectedCount }} users
|
|
</Trans>
|
|
</Button>
|
|
)}
|
|
<Button variant="primary" onPress={onAddUser}>
|
|
<Trans>Add new user</Trans>
|
|
</Button>
|
|
</SpaceBetween>
|
|
</View>
|
|
</View>
|
|
</SelectedProvider>
|
|
);
|
|
}
|
|
|
|
type EmptyMessageProps = {
|
|
text: string;
|
|
style?: CSSProperties;
|
|
};
|
|
|
|
function EmptyMessage({ text, style }: EmptyMessageProps) {
|
|
return (
|
|
<View
|
|
style={{
|
|
textAlign: 'center',
|
|
color: theme.pageTextSubdued,
|
|
fontStyle: 'italic',
|
|
fontSize: 13,
|
|
marginTop: 5,
|
|
...style,
|
|
}}
|
|
>
|
|
{text}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
type ManageUsersProps = {
|
|
isModal: boolean;
|
|
};
|
|
|
|
export function UserDirectory({ isModal }: ManageUsersProps) {
|
|
return <UserDirectoryContent isModal={isModal} />;
|
|
}
|
|
|
|
type UsersListProps = {
|
|
users: UserEntity[];
|
|
selectedItems: Set<string>;
|
|
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 (
|
|
<View>
|
|
{users.map(user => {
|
|
const hovered = hoveredUser === user.id;
|
|
const selected = selectedItems.has(user.id);
|
|
|
|
return (
|
|
<UserDirectoryRow
|
|
key={user.id}
|
|
user={user}
|
|
hovered={hovered}
|
|
selected={selected}
|
|
onHover={onHover}
|
|
onEditUser={onEditUser}
|
|
/>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
}
|