summaryrefslogtreecommitdiffhomepage
path: root/gui/src
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2023-06-14 10:25:32 +0200
committerOskar Nyberg <oskar@mullvad.net>2023-06-14 10:25:32 +0200
commit28069e86a364aa7f8e7fad51924ad3e0345e13d3 (patch)
tree95d4f7b21a659b93f34c45ef1718f81a7ab8a8e5 /gui/src
parent499e7516c4537c4e8fa6983c1e6698df32f1e9de (diff)
parent0aadb762da306adc7e305388496a384fdb2c4181 (diff)
downloadmullvadvpn-28069e86a364aa7f8e7fad51924ad3e0345e13d3.tar.xz
mullvadvpn-28069e86a364aa7f8e7fad51924ad3e0345e13d3.zip
Merge branch 'device-improvements'
Diffstat (limited to 'gui/src')
-rw-r--r--gui/src/renderer/components/Account.tsx9
-rw-r--r--gui/src/renderer/components/AccountStyles.tsx5
-rw-r--r--gui/src/renderer/components/DeviceInfoButton.tsx57
-rw-r--r--gui/src/renderer/components/ExpiredAccountErrorView.tsx19
-rw-r--r--gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx8
-rw-r--r--gui/src/renderer/components/InfoButton.tsx3
-rw-r--r--gui/src/renderer/components/NotificationArea.tsx29
-rw-r--r--gui/src/renderer/components/NotificationBanner.tsx37
-rw-r--r--gui/src/renderer/redux/account/actions.ts10
-rw-r--r--gui/src/renderer/redux/account/reducers.ts18
-rw-r--r--gui/src/shared/notifications/new-device.ts32
-rw-r--r--gui/src/shared/notifications/notification.ts4
12 files changed, 201 insertions, 30 deletions
diff --git a/gui/src/renderer/components/Account.tsx b/gui/src/renderer/components/Account.tsx
index 2841e4811c..bd3bafbafc 100644
--- a/gui/src/renderer/components/Account.tsx
+++ b/gui/src/renderer/components/Account.tsx
@@ -25,11 +25,13 @@ import {
AccountRows,
AccountRowValue,
DeviceRowValue,
+ StyledDeviceNameRow,
StyledSpinnerContainer,
} from './AccountStyles';
import AccountTokenLabel from './AccountTokenLabel';
import * as AppButton from './AppButton';
import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from './AriaGroup';
+import DeviceInfoButton from './DeviceInfoButton';
import ImageView from './ImageView';
import { BackAction } from './KeyboardNavigation';
import { Footer, Layout, SettingsContainer } from './Layout';
@@ -185,7 +187,12 @@ function LogoutDialog() {
function DeviceNameRow() {
const deviceName = useSelector((state) => state.account.deviceName);
- return <DeviceRowValue>{deviceName}</DeviceRowValue>;
+ return (
+ <StyledDeviceNameRow>
+ <DeviceRowValue>{deviceName}</DeviceRowValue>
+ <DeviceInfoButton />
+ </StyledDeviceNameRow>
+ );
}
function AccountNumberRow() {
diff --git a/gui/src/renderer/components/AccountStyles.tsx b/gui/src/renderer/components/AccountStyles.tsx
index 5cb934134a..706abb1bdf 100644
--- a/gui/src/renderer/components/AccountStyles.tsx
+++ b/gui/src/renderer/components/AccountStyles.tsx
@@ -50,3 +50,8 @@ export const StyledSpinnerContainer = styled.div({
justifyContent: 'center',
padding: '8px 0',
});
+
+export const StyledDeviceNameRow = styled.div({
+ display: 'flex',
+ flexDirection: 'row',
+});
diff --git a/gui/src/renderer/components/DeviceInfoButton.tsx b/gui/src/renderer/components/DeviceInfoButton.tsx
new file mode 100644
index 0000000000..4839866972
--- /dev/null
+++ b/gui/src/renderer/components/DeviceInfoButton.tsx
@@ -0,0 +1,57 @@
+import styled from 'styled-components';
+
+import { messages } from '../../shared/gettext';
+import { useBoolean } from '../lib/utilityHooks';
+import * as AppButton from './AppButton';
+import { InfoIcon } from './InfoButton';
+import { ModalAlert, ModalAlertType, ModalMessage } from './Modal';
+
+const StyledInfoButton = styled.button({
+ margin: '0 0 0 10px',
+ borderWidth: 0,
+ padding: 0,
+ cursor: 'default',
+ backgroundColor: 'transparent',
+});
+
+export default function DeviceInfoButton() {
+ const [deviceHelpVisible, showDeviceHelp, hideDeviceHelp] = useBoolean();
+
+ return (
+ <>
+ <StyledInfoButton
+ onClick={showDeviceHelp}
+ aria-label={messages.pgettext('accessibility', 'More information')}>
+ <InfoIcon size={16} />
+ </StyledInfoButton>
+ <ModalAlert
+ isOpen={deviceHelpVisible}
+ type={ModalAlertType.info}
+ buttons={[
+ <AppButton.BlueButton key="back" onClick={hideDeviceHelp}>
+ {messages.gettext('Got it!')}
+ </AppButton.BlueButton>,
+ ]}
+ close={hideDeviceHelp}>
+ <ModalMessage>
+ {messages.pgettext(
+ 'device-management',
+ 'This is the name assigned to the device. Each device logged in on a Mullvad account gets a unique name that helps you identify it when you manage your devices in the app or on the website.',
+ )}
+ </ModalMessage>
+ <ModalMessage>
+ {messages.pgettext(
+ 'device-management',
+ 'You can have up to 5 devices logged in on one Mullvad account.',
+ )}
+ </ModalMessage>
+ <ModalMessage>
+ {messages.pgettext(
+ 'device-management',
+ 'If you log out, the device and the device name is removed. When you log back in again, the device will get a new name.',
+ )}
+ </ModalMessage>
+ </ModalAlert>
+ </>
+ );
+}
diff --git a/gui/src/renderer/components/ExpiredAccountErrorView.tsx b/gui/src/renderer/components/ExpiredAccountErrorView.tsx
index d5af378cca..3049ba8075 100644
--- a/gui/src/renderer/components/ExpiredAccountErrorView.tsx
+++ b/gui/src/renderer/components/ExpiredAccountErrorView.tsx
@@ -4,6 +4,7 @@ import { sprintf } from 'sprintf-js';
import { links } from '../../config.json';
import { messages } from '../../shared/gettext';
import log from '../../shared/logging';
+import { capitalizeEveryWord } from '../../shared/string-helpers';
import { useAppContext } from '../context';
import { useHistory } from '../lib/history';
import { RoutePath } from '../lib/routes';
@@ -11,6 +12,7 @@ import { useSelector } from '../redux/store';
import * as AppButton from './AppButton';
import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from './AriaGroup';
import * as Cell from './cell';
+import DeviceInfoButton from './DeviceInfoButton';
import {
StyledAccountTokenContainer,
StyledAccountTokenLabel,
@@ -18,6 +20,7 @@ import {
StyledBody,
StyledContainer,
StyledCustomScrollbars,
+ StyledDeviceLabel,
StyledHeader,
StyledMessage,
StyledModalCellContainer,
@@ -117,6 +120,22 @@ function WelcomeView() {
</StyledAccountTokenContainer>
</StyledAccountTokenMessage>
+ <StyledDeviceLabel>
+ <span>
+ {sprintf(
+ // TRANSLATORS: A label that will display the newly created device name to inform the user
+ // TRANSLATORS: about it.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(deviceName)s - The name of the current device
+ messages.pgettext('device-management', 'Device name: %(deviceName)s'),
+ {
+ deviceName: capitalizeEveryWord(account.deviceName ?? ''),
+ },
+ )}
+ </span>
+ <DeviceInfoButton />
+ </StyledDeviceLabel>
+
<StyledMessage>
{sprintf('%(introduction)s %(recoveryMessage)s', {
introduction: messages.pgettext(
diff --git a/gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx b/gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx
index 17c02cfc3c..753ecdacef 100644
--- a/gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx
+++ b/gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx
@@ -69,3 +69,11 @@ export const StyledAccountTokenContainer = styled.div({
height: '50px',
alignItems: 'center',
});
+
+export const StyledDeviceLabel = styled.span(tinyText, {
+ display: 'flex',
+ alignItems: 'middle',
+ lineHeight: '20px',
+ marginBottom: '18px',
+ color: colors.white,
+});
diff --git a/gui/src/renderer/components/InfoButton.tsx b/gui/src/renderer/components/InfoButton.tsx
index 52d92e18d6..302b043ef4 100644
--- a/gui/src/renderer/components/InfoButton.tsx
+++ b/gui/src/renderer/components/InfoButton.tsx
@@ -17,13 +17,14 @@ const StyledInfoButton = styled.button({
interface IInfoIconProps {
className?: string;
+ size?: number;
}
export function InfoIcon(props: IInfoIconProps) {
return (
<ImageView
source="icon-info"
- width={18}
+ width={props.size ?? 18}
tintColor={colors.white}
tintHoverColor={colors.white80}
className={props.className}
diff --git a/gui/src/renderer/components/NotificationArea.tsx b/gui/src/renderer/components/NotificationArea.tsx
index 4923bacc7c..79fb3ef17c 100644
--- a/gui/src/renderer/components/NotificationArea.tsx
+++ b/gui/src/renderer/components/NotificationArea.tsx
@@ -5,6 +5,7 @@ import styled from 'styled-components';
import { colors } from '../../config.json';
import { messages } from '../../shared/gettext';
import log from '../../shared/logging';
+import { NewDeviceNotificationProvider } from '../../shared/notifications/new-device';
import {
BlockWhenDisconnectedNotificationProvider,
CloseToAccountExpiryNotificationProvider,
@@ -19,14 +20,18 @@ import {
UpdateAvailableNotificationProvider,
} from '../../shared/notifications/notification';
import { useAppContext } from '../context';
+import useActions from '../lib/actionsHook';
import { transitions, useHistory } from '../lib/history';
+import { formatHtml } from '../lib/html-formatter';
import { RoutePath } from '../lib/routes';
+import accountActions from '../redux/account/actions';
import { IReduxState } from '../redux/store';
import * as AppButton from './AppButton';
import { ModalAlert, ModalAlertType, ModalMessage } from './Modal';
import {
NotificationActions,
NotificationBanner,
+ NotificationCloseAction,
NotificationContent,
NotificationIndicator,
NotificationOpenLinkAction,
@@ -40,7 +45,7 @@ interface IProps {
}
export default function NotificationArea(props: IProps) {
- const accountExpiry = useSelector((state: IReduxState) => state.account.expiry);
+ const account = useSelector((state: IReduxState) => state.account);
const locale = useSelector((state: IReduxState) => state.userInterface.locale);
const tunnelState = useSelector((state: IReduxState) => state.connection.status);
const version = useSelector((state: IReduxState) => state.version);
@@ -52,6 +57,8 @@ export default function NotificationArea(props: IProps) {
state.settings.splitTunneling && state.settings.splitTunnelingApplications.length > 0,
);
+ const { hideNewDeviceBanner } = useActions(accountActions);
+
const notificationProviders: InAppNotificationProvider[] = [
new ConnectingNotificationProvider({ tunnelState }),
new ReconnectingNotificationProvider(tunnelState),
@@ -65,13 +72,20 @@ export default function NotificationArea(props: IProps) {
new UnsupportedVersionNotificationProvider(version),
];
- if (accountExpiry) {
+ if (account.expiry) {
notificationProviders.push(
- new CloseToAccountExpiryNotificationProvider({ accountExpiry, locale }),
+ new CloseToAccountExpiryNotificationProvider({ accountExpiry: account.expiry, locale }),
);
}
- notificationProviders.push(new UpdateAvailableNotificationProvider(version));
+ notificationProviders.push(
+ new NewDeviceNotificationProvider({
+ shouldDisplay: account.status.type === 'ok' && account.status.newDeviceBanner,
+ deviceName: account.deviceName ?? '',
+ close: hideNewDeviceBanner,
+ }),
+ new UpdateAvailableNotificationProvider(version),
+ );
const notificationProvider = notificationProviders.find((notification) =>
notification.mayDisplay(),
@@ -86,7 +100,7 @@ export default function NotificationArea(props: IProps) {
<NotificationIndicator type={notification.indicator} />
<NotificationContent role="status" aria-live="polite">
<NotificationTitle>{notification.title}</NotificationTitle>
- <NotificationSubtitle>{notification.subtitle}</NotificationSubtitle>
+ <NotificationSubtitle>{formatHtml(notification.subtitle ?? '')}</NotificationSubtitle>
</NotificationContent>
{notification.action && <NotificationActionWrapper action={notification.action} />}
</NotificationBanner>
@@ -128,6 +142,9 @@ function NotificationActionWrapper(props: INotificationActionWrapperProps) {
case 'troubleshoot-dialog':
setTroubleshootInfo(props.action.troubleshoot);
break;
+ case 'close':
+ props.action.close();
+ break;
}
}
@@ -154,6 +171,8 @@ function NotificationActionWrapper(props: INotificationActionWrapperProps) {
</>
);
break;
+ case 'close':
+ actionComponent = <NotificationCloseAction onClick={handleClick} />;
}
}
diff --git a/gui/src/renderer/components/NotificationBanner.tsx b/gui/src/renderer/components/NotificationBanner.tsx
index 4f20d315c8..18e18ce4f5 100644
--- a/gui/src/renderer/components/NotificationBanner.tsx
+++ b/gui/src/renderer/components/NotificationBanner.tsx
@@ -35,29 +35,23 @@ export const NotificationActionButton = styled(AppButton.SimpleButton)({
border: 'none',
});
-export const NotificationOpenLinkActionIcon = styled(ImageView)({
- [NotificationActionButton + ':hover &']: {
+export const NotificationActionButtonInner = styled(ImageView)({
+ [NotificationActionButton + ':hover &&']: {
backgroundColor: colors.white80,
},
});
-export const NotificationTroubleshootDialogActionIcon = styled(ImageView)({
- [NotificationActionButton + ':hover &']: {
- backgroundColor: colors.white80,
- },
-});
-
-interface INotifcationOpenLinkActionProps {
+interface NotificationActionProps {
onClick: () => Promise<void>;
}
-export function NotificationOpenLinkAction(props: INotifcationOpenLinkActionProps) {
+export function NotificationOpenLinkAction(props: NotificationActionProps) {
return (
<AppButton.BlockingButton onClick={props.onClick}>
<NotificationActionButton
aria-describedby={NOTIFICATION_AREA_ID}
aria-label={messages.gettext('Open URL')}>
- <NotificationOpenLinkActionIcon
+ <NotificationActionButtonInner
height={12}
width={12}
tintColor={colors.white60}
@@ -68,19 +62,13 @@ export function NotificationOpenLinkAction(props: INotifcationOpenLinkActionProp
);
}
-interface INotifcationTroubleshootDialogActionProps {
- onClick: () => Promise<void>;
-}
-
-export function NotificationTroubleshootDialogAction(
- props: INotifcationTroubleshootDialogActionProps,
-) {
+export function NotificationTroubleshootDialogAction(props: NotificationActionProps) {
return (
<NotificationActionButton
aria-describedby={NOTIFICATION_AREA_ID}
aria-label={messages.gettext('Troubleshoot')}
onClick={props.onClick}>
- <NotificationOpenLinkActionIcon
+ <NotificationActionButtonInner
height={12}
width={12}
tintColor={colors.white60}
@@ -90,6 +78,17 @@ export function NotificationTroubleshootDialogAction(
);
}
+export function NotificationCloseAction(props: NotificationActionProps) {
+ return (
+ <NotificationActionButton
+ aria-describedby={NOTIFICATION_AREA_ID}
+ aria-label={messages.pgettext('accessibility', 'Close notification')}
+ onClick={props.onClick}>
+ <NotificationActionButtonInner source="icon-close" width={16} tintColor={colors.white60} />
+ </NotificationActionButton>
+ );
+}
+
export const NotificationContent = styled.div.attrs({ id: NOTIFICATION_AREA_ID })({
display: 'flex',
flexDirection: 'column',
diff --git a/gui/src/renderer/redux/account/actions.ts b/gui/src/renderer/redux/account/actions.ts
index 61fc09d981..ece9719952 100644
--- a/gui/src/renderer/redux/account/actions.ts
+++ b/gui/src/renderer/redux/account/actions.ts
@@ -60,6 +60,10 @@ interface IAccountSetupFinished {
type: 'ACCOUNT_SETUP_FINISHED';
}
+interface IHideNewDeviceBanner {
+ type: 'HIDE_NEW_DEVICE_BANNER';
+}
+
interface IUpdateAccountTokenAction {
type: 'UPDATE_ACCOUNT_TOKEN';
accountToken: AccountToken;
@@ -94,6 +98,7 @@ export type AccountAction =
| ICreateAccountFailed
| IAccountCreated
| IAccountSetupFinished
+ | IHideNewDeviceBanner
| IUpdateAccountTokenAction
| IUpdateAccountHistoryAction
| IUpdateAccountExpiryAction
@@ -187,6 +192,10 @@ function accountSetupFinished(): IAccountSetupFinished {
return { type: 'ACCOUNT_SETUP_FINISHED' };
}
+function hideNewDeviceBanner(): IHideNewDeviceBanner {
+ return { type: 'HIDE_NEW_DEVICE_BANNER' };
+}
+
function updateAccountToken(accountToken: AccountToken): IUpdateAccountTokenAction {
return {
type: 'UPDATE_ACCOUNT_TOKEN',
@@ -229,6 +238,7 @@ export default {
createAccountFailed,
accountCreated,
accountSetupFinished,
+ hideNewDeviceBanner,
updateAccountToken,
updateAccountHistory,
updateAccountExpiry,
diff --git a/gui/src/renderer/redux/account/reducers.ts b/gui/src/renderer/redux/account/reducers.ts
index a78a777c0a..6f9e558b03 100644
--- a/gui/src/renderer/redux/account/reducers.ts
+++ b/gui/src/renderer/redux/account/reducers.ts
@@ -4,7 +4,8 @@ import { ReduxAction } from '../store';
type LoginMethod = 'existing_account' | 'new_account';
export type LoginState =
| { type: 'none'; deviceRevoked: boolean }
- | { type: 'logging in' | 'ok'; method: LoginMethod }
+ | { type: 'logging in'; method: LoginMethod }
+ | { type: 'ok'; method: LoginMethod; newDeviceBanner: boolean }
| { type: 'too many devices'; method: LoginMethod }
| { type: 'failed'; method: 'existing_account'; error: AccountDataError['error'] }
| { type: 'failed'; method: 'new_account'; error: Error };
@@ -42,7 +43,7 @@ export default function (
case 'LOGGED_IN':
return {
...state,
- status: { type: 'ok', method: 'existing_account' },
+ status: { type: 'ok', method: 'existing_account', newDeviceBanner: false },
accountToken: action.accountToken,
deviceName: action.deviceName,
};
@@ -97,7 +98,7 @@ export default function (
case 'ACCOUNT_CREATED':
return {
...state,
- status: { type: 'ok', method: 'new_account' },
+ status: { type: 'ok', method: 'new_account', newDeviceBanner: true },
accountToken: action.accountToken,
deviceName: action.deviceName,
expiry: action.expiry,
@@ -105,7 +106,16 @@ export default function (
case 'ACCOUNT_SETUP_FINISHED':
return {
...state,
- status: { type: 'ok', method: 'existing_account' },
+ status: { type: 'ok', method: 'existing_account', newDeviceBanner: true },
+ };
+ case 'HIDE_NEW_DEVICE_BANNER':
+ if (state.status.type !== 'ok') {
+ return state;
+ }
+
+ return {
+ ...state,
+ status: { ...state.status, newDeviceBanner: false },
};
case 'UPDATE_ACCOUNT_TOKEN':
return {
diff --git a/gui/src/shared/notifications/new-device.ts b/gui/src/shared/notifications/new-device.ts
new file mode 100644
index 0000000000..7d0fe9f299
--- /dev/null
+++ b/gui/src/shared/notifications/new-device.ts
@@ -0,0 +1,32 @@
+import { sprintf } from 'sprintf-js';
+
+import { messages } from '../../shared/gettext';
+import { capitalizeEveryWord } from '../string-helpers';
+import { InAppNotification, InAppNotificationProvider } from './notification';
+
+interface NewDeviceNotificationContext {
+ shouldDisplay: boolean;
+ deviceName: string;
+ close: () => void;
+}
+
+export class NewDeviceNotificationProvider implements InAppNotificationProvider {
+ public constructor(private context: NewDeviceNotificationContext) {}
+
+ public mayDisplay = () => this.context.shouldDisplay;
+
+ public getInAppNotification(): InAppNotification {
+ return {
+ indicator: 'success',
+ title: messages.pgettext('in-app-notifications', 'NEW DEVICE CREATED'),
+ subtitle: sprintf(
+ messages.pgettext(
+ 'in-app-notifications',
+ 'Welcome, this device is now called <b>%(deviceName)s</b>. For more details see the info button in Account.',
+ ),
+ { deviceName: capitalizeEveryWord(this.context.deviceName) },
+ ),
+ action: { type: 'close', close: this.context.close },
+ };
+ }
+}
diff --git a/gui/src/shared/notifications/notification.ts b/gui/src/shared/notifications/notification.ts
index 88bddfa9f5..b8727739ef 100644
--- a/gui/src/shared/notifications/notification.ts
+++ b/gui/src/shared/notifications/notification.ts
@@ -15,6 +15,10 @@ export type InAppNotificationAction =
| {
type: 'troubleshoot-dialog';
troubleshoot: InAppNotificationTroubleshootInfo;
+ }
+ | {
+ type: 'close';
+ close: () => void;
};
export type InAppNotificationIndicatorType = 'success' | 'warning' | 'error';