summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOliver <oliver@mohlin.dev>2025-09-12 16:36:02 +0200
committerTobias Järvelöv <tobias.jarvelov@mullvad.net>2025-10-10 13:36:20 +0200
commit92a3f9c98201b2787c8ce9098229b3f3ecbd55be (patch)
treef6af5c6508a9e1eb8d257ef970d9cd6290703022
parente87ad3351a4c9851e3cec108cd89089b26fb8d6a (diff)
downloadmullvadvpn-92a3f9c98201b2787c8ce9098229b3f3ecbd55be.tar.xz
mullvadvpn-92a3f9c98201b2787c8ce9098229b3f3ecbd55be.zip
Use new components in too many devices view
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/too-many-devices/TooManyDevicesView.tsx307
1 files changed, 49 insertions, 258 deletions
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/too-many-devices/TooManyDevicesView.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/too-many-devices/TooManyDevicesView.tsx
index 9b5b823e47..65d26dc253 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/views/too-many-devices/TooManyDevicesView.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/too-many-devices/TooManyDevicesView.tsx
@@ -1,100 +1,37 @@
import { useCallback } from 'react';
-import { sprintf } from 'sprintf-js';
import styled from 'styled-components';
import { IDevice } from '../../../../shared/daemon-rpc-types';
import { messages } from '../../../../shared/gettext';
-import log from '../../../../shared/logging';
import { RoutePath } from '../../../../shared/routes';
-import { capitalizeEveryWord } from '../../../../shared/string-helpers';
import { useAppContext } from '../../../context';
-import { Button, Flex, IconButton, Spinner } from '../../../lib/components';
+import { Button, Flex, Text } from '../../../lib/components';
import { FlexColumn } from '../../../lib/components/flex-column';
-import { colors } from '../../../lib/foundations';
+import { View } from '../../../lib/components/view';
import { TransitionType, useHistory } from '../../../lib/history';
-import { formatHtml } from '../../../lib/html-formatter';
import { IconBadge, IconBadgeProps } from '../../../lib/icon-badge';
-import { useBoolean } from '../../../lib/utility-hooks';
import { useSelector } from '../../../redux/store';
import { AppMainHeader } from '../../app-main-header';
-import * as Cell from '../../cell';
-import { bigText, measurements, normalText, tinyText } from '../../common-styles';
import CustomScrollbars from '../../CustomScrollbars';
-import { Footer, Layout, SettingsContainer } from '../../Layout';
+import { DeviceListItem } from '../../device-list-item';
import List from '../../List';
-import { ModalAlert, ModalAlertType, ModalContainer, ModalMessage } from '../../Modal';
const StyledCustomScrollbars = styled(CustomScrollbars)({
flex: 1,
});
-const StyledContainer = styled(SettingsContainer)({
- minHeight: '100%',
-});
-
-const StyledBody = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- paddingBottom: 'auto',
-});
-
-const StyledTitle = styled.span(bigText, {
- lineHeight: '38px',
- margin: `0 ${measurements.horizontalViewMargin} 8px`,
- color: colors.white,
-});
-
-const StyledLabel = styled.span({
- fontFamily: 'Open Sans',
- fontSize: '12px',
- fontWeight: 600,
- lineHeight: '20px',
- color: colors.white,
- margin: `0 ${measurements.horizontalViewMargin} 18px`,
-});
-
-const StyledSpacer = styled.div({
- flex: '1',
-});
-
-const StyledDeviceInfo = styled(Cell.Label)({
- display: 'flex',
- flexDirection: 'column',
- marginTop: '9px',
- marginBottom: '9px',
-});
-
-const StyledDeviceName = styled.span(normalText, {
- fontWeight: 'normal',
- lineHeight: '20px',
- textTransform: 'capitalize',
-});
-
-const StyledDeviceDate = styled.span(tinyText, {
- fontSize: '10px',
- lineHeight: '10px',
- color: colors.whiteAlpha60,
-});
-
export function TooManyDevicesView() {
const { reset } = useHistory();
- const { removeDevice, login, cancelLogin } = useAppContext();
+ const { login, cancelLogin } = useAppContext();
const accountNumber = useSelector((state) => state.account.accountNumber)!;
const devices = useSelector((state) => state.account.devices);
const loginState = useSelector((state) => state.account.status);
- const onRemoveDevice = useCallback(
- async (deviceId: string) => {
- await removeDevice({ accountNumber, deviceId });
- },
- [removeDevice, accountNumber],
- );
-
const continueLogin = useCallback(() => {
void login(accountNumber);
reset(RoutePath.login, { transition: TransitionType.pop });
}, [reset, login, accountNumber]);
+
const cancel = useCallback(() => {
cancelLogin();
reset(RoutePath.login, { transition: TransitionType.pop });
@@ -107,202 +44,56 @@ export function TooManyDevicesView() {
const continueButtonDisabled = devices.length === 5 || loginState.type !== 'too many devices';
return (
- <ModalContainer>
- <Layout>
- <AppMainHeader>
- <AppMainHeader.SettingsButton />
- </AppMainHeader>
- <StyledCustomScrollbars fillContainer>
- <StyledContainer>
- <StyledBody>
- <Flex $justifyContent="center" $margin={{ top: 'large', bottom: 'medium' }}>
- <IconBadge key={imageSource} state={imageSource} />
- </Flex>
- {devices !== undefined && (
- <>
- <StyledTitle data-testid="title">{title}</StyledTitle>
- <StyledLabel>{subtitle}</StyledLabel>
- <DeviceList devices={devices} onRemoveDevice={onRemoveDevice} />
- </>
- )}
- </StyledBody>
-
- {devices !== undefined && (
- <Footer>
- <FlexColumn $gap="medium">
- <Button
- variant="success"
- onClick={continueLogin}
- disabled={continueButtonDisabled}>
- <Button.Text>
- {
- // TRANSLATORS: Button for continuing login process.
- messages.pgettext('device-management', 'Continue with login')
- }
- </Button.Text>
- </Button>
- <Button onClick={cancel}>
- <Button.Text>{messages.gettext('Back')}</Button.Text>
- </Button>
- </FlexColumn>
- </Footer>
- )}
- </StyledContainer>
- </StyledCustomScrollbars>
- </Layout>
- </ModalContainer>
- );
-}
-
-interface IDeviceListProps {
- devices: Array<IDevice>;
- onRemoveDevice: (deviceId: string) => Promise<void>;
-}
+ <View backgroundColor="darkBlue">
+ <AppMainHeader>
+ <AppMainHeader.SettingsButton />
+ </AppMainHeader>
+ <StyledCustomScrollbars fillContainer>
+ <FlexColumn $gap="large">
+ <View.Container>
+ <Flex $justifyContent="center" $margin={{ top: 'large' }}>
+ <IconBadge key={imageSource} state={imageSource} />
+ </Flex>
+ </View.Container>
+ {devices !== undefined && (
+ <>
+ <View.Container $gap="small">
+ <Text variant="titleLarge" data-testid="title">
+ {title}
+ </Text>
+ <Text variant="labelTiny">{subtitle}</Text>
+ </View.Container>
+ <div>
+ <List items={devices} getKey={getDeviceKey}>
+ {(device) => <DeviceListItem device={device} />}
+ </List>
+ </div>
+ </>
+ )}
-function DeviceList(props: IDeviceListProps) {
- return (
- <StyledSpacer>
- <List items={props.devices} getKey={getDeviceKey}>
- {(device) => <Device device={device} onRemove={props.onRemoveDevice} />}
- </List>
- </StyledSpacer>
+ {devices !== undefined && (
+ <View.Container $gap="medium" $padding={{ bottom: 'large' }}>
+ <Button variant="success" onClick={continueLogin} disabled={continueButtonDisabled}>
+ <Button.Text>
+ {
+ // TRANSLATORS: Button for continuing login process.
+ messages.pgettext('device-management', 'Continue with login')
+ }
+ </Button.Text>
+ </Button>
+ <Button onClick={cancel}>
+ <Button.Text>{messages.gettext('Back')}</Button.Text>
+ </Button>
+ </View.Container>
+ )}
+ </FlexColumn>
+ </StyledCustomScrollbars>
+ </View>
);
}
const getDeviceKey = (device: IDevice): string => device.id;
-interface IDeviceProps {
- device: IDevice;
- onRemove: (deviceId: string) => Promise<void>;
-}
-
-function Device(props: IDeviceProps) {
- const { onRemove: propsOnRemove } = props;
-
- const { fetchDevices } = useAppContext();
- const accountNumber = useSelector((state) => state.account.accountNumber)!;
- const [confirmationVisible, showConfirmation, hideConfirmation] = useBoolean(false);
- const [deleting, setDeleting, unsetDeleting] = useBoolean(false);
- const [error, setError, resetError] = useBoolean(false);
-
- const handleError = useCallback(
- async (error: Error) => {
- log.error(`Failed to remove device: ${error.message}`);
-
- let devices: Array<IDevice> | undefined = undefined;
- try {
- devices = await fetchDevices(accountNumber);
- } catch {
- /* no-op */
- }
-
- if (devices === undefined || devices.find((device) => device.id === props.device.id)) {
- hideConfirmation();
- unsetDeleting();
- setError();
- }
- },
- [fetchDevices, accountNumber, props.device.id, hideConfirmation, unsetDeleting, setError],
- );
-
- const onRemove = useCallback(async () => {
- setDeleting();
- hideConfirmation();
- try {
- await propsOnRemove(props.device.id);
- } catch (e) {
- await handleError(e as Error);
- }
- }, [propsOnRemove, props.device.id, hideConfirmation, setDeleting, handleError]);
-
- const capitalizedDeviceName = capitalizeEveryWord(props.device.name);
- const createdDate = props.device.created.toISOString().split('T')[0];
-
- return (
- <>
- <Cell.Container>
- <StyledDeviceInfo>
- <StyledDeviceName aria-hidden>{props.device.name}</StyledDeviceName>
- <StyledDeviceDate>
- {sprintf(
- // TRANSLATORS: Label informing the user when a device was created.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(createdDate)s - The creation date of the device.
- messages.pgettext('device-management', 'Created: %(createdDate)s'),
- {
- createdDate,
- },
- )}
- </StyledDeviceDate>
- </StyledDeviceInfo>
- {deleting ? (
- <Spinner />
- ) : (
- <IconButton
- variant="secondary"
- onClick={showConfirmation}
- aria-label={sprintf(
- // TRANSLATORS: Button action description provided to accessibility tools such as screen
- // TRANSLATORS: readers.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(deviceName)s - The device name to remove.
- messages.pgettext('accessibility', 'Remove device named %(deviceName)s'),
- { deviceName: props.device.name },
- )}>
- <IconButton.Icon icon="cross-circle" />
- </IconButton>
- )}
- </Cell.Container>
- <ModalAlert
- isOpen={confirmationVisible}
- type={ModalAlertType.warning}
- iconColor={colors.red}
- buttons={[
- <Button variant="destructive" key="remove" onClick={onRemove} disabled={deleting}>
- <Button.Text>
- {
- // TRANSLATORS: Button label for confirming logout of another device.
- messages.pgettext('device-management', 'Yes, log out device')
- }
- </Button.Text>
- </Button>,
- <Button key="back" onClick={hideConfirmation} disabled={deleting}>
- <Button.Text>{messages.gettext('Back')}</Button.Text>
- </Button>,
- ]}
- close={hideConfirmation}>
- <ModalMessage>
- {formatHtml(
- sprintf(
- // TRANSLATORS: Text displayed above button which logs out another device.
- // TRANSLATORS: The text enclosed in "<b></b>" will appear bold.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(deviceName)s - The name of the device to log out.
- messages.pgettext(
- 'device-management',
- 'Are you sure you want to log <b>%(deviceName)s</b> out?',
- ),
- { deviceName: capitalizedDeviceName },
- ),
- )}
- </ModalMessage>
- </ModalAlert>
- <ModalAlert
- isOpen={error}
- type={ModalAlertType.warning}
- iconColor={colors.red}
- buttons={[
- <Button key="close" onClick={resetError}>
- <Button.Text>{messages.gettext('Close')}</Button.Text>
- </Button>,
- ]}
- close={resetError}
- message={messages.pgettext('device-management', 'Failed to remove device')}
- />
- </>
- );
-}
-
function getIconSource(devices: Array<IDevice>): IconBadgeProps['state'] {
if (devices.length === 5) {
return 'negative';