Files
actual/packages/desktop-client/src/components/admin/UserDirectory/UserDirectory.tsx
T
Matiss Janis AboltinsandClaude Opus 4.6 d8317c44b7 [AI] Migrate desktop-client to subpath imports (#7446)
* [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]>
2026-04-10 17:03:45 +00:00

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