Polish user settings and warnings (#2425)

# What this PR does

* users table: added warnings: No default notifications set, No
important notifications set
* users table: removed warnings when messenger is not configured (e.g.
telegram channels are not connected -> no need to show telegram warning
in users table)
* users table: moved current user to first place
* user profile: cleaned up and added hints to notification channel
connectors
* user profile: cleaned up and added hints to calendar sync
* chatops-slack: cleaned up and added hints to slack settings

fixes https://github.com/grafana/oncall/issues/2418

## Which issue(s) this PR fixes

## Checklist

- [ ] Unit, integration, and e2e (if applicable) tests updated
- [ ] Documentation added (or `pr:no public docs` PR label added if not
required)
- [ ] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
This commit is contained in:
Ildar Iskhakov 2023-07-17 18:34:58 +08:00 committed by GitHub
parent 35e4cf5bac
commit 8367a1ed4c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 490 additions and 410 deletions

View file

@ -77,6 +77,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Modified DRF pagination class used by `GET /api/internal/v1/alert_receive_channels` and `GET /api/internal/v1/schedules`
endpoints so that the `next` and `previous` pagination links are properly set when OnCall is run behind
a reverse proxy by @joeyorlando ([#2467](https://github.com/grafana/oncall/pull/2467))
- Polish user settings and warnings ([#2425](https://github.com/grafana/oncall/pull/2425))
### Fixed

View file

@ -249,9 +249,8 @@ class UserView(
return queryset.order_by("id")
def list(self, request, *args, **kwargs):
def list(self, request, *args, **kwargs) -> Response:
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
if page is not None:
context = {"request": self.request, "format": self.format_kwarg, "view": self}
@ -272,7 +271,7 @@ class UserView(
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
def retrieve(self, request, *args, **kwargs):
def retrieve(self, request, *args, **kwargs) -> Response:
context = {"request": self.request, "format": self.format_kwarg, "view": self}
try:
instance = self.get_object()
@ -292,7 +291,7 @@ class UserView(
serializer = self.get_serializer(instance, context=context)
return Response(serializer.data)
def wrong_team_response(self):
def wrong_team_response(self) -> Response:
"""
This method returns 403 and {"error_code": "wrong_team", "owner_team": {"name", "id", "email", "avatar_url"}}.
Used in case if a requested instance doesn't belong to user's current_team.
@ -314,12 +313,12 @@ class UserView(
status=status.HTTP_403_FORBIDDEN,
)
def current(self, request):
def current(self, request) -> Response:
serializer = UserSerializer(self.get_queryset().get(pk=self.request.user.pk))
return Response(serializer.data)
@action(detail=False, methods=["get"])
def timezone_options(self, request):
def timezone_options(self, request) -> Response:
return Response(pytz.common_timezones)
@action(
@ -327,7 +326,7 @@ class UserView(
methods=["get"],
throttle_classes=[GetPhoneVerificationCodeThrottlerPerUser, GetPhoneVerificationCodeThrottlerPerOrg],
)
def get_verification_code(self, request, pk):
def get_verification_code(self, request, pk) -> Response:
logger.info("get_verification_code: validating reCAPTCHA code")
valid = check_recaptcha_internal_api(request, "mobile_verification_code")
if not valid:
@ -354,7 +353,7 @@ class UserView(
methods=["get"],
throttle_classes=[GetPhoneVerificationCodeThrottlerPerUser, GetPhoneVerificationCodeThrottlerPerOrg],
)
def get_verification_call(self, request, pk):
def get_verification_call(self, request, pk) -> Response:
logger.info("get_verification_code_via_call: validating reCAPTCHA code")
valid = check_recaptcha_internal_api(request, "mobile_verification_code")
if not valid:
@ -381,7 +380,7 @@ class UserView(
methods=["put"],
throttle_classes=[VerifyPhoneNumberThrottlerPerUser, VerifyPhoneNumberThrottlerPerOrg],
)
def verify_number(self, request, pk):
def verify_number(self, request, pk) -> Response:
target_user = self.get_object()
code = request.query_params.get("token", None)
if not code:
@ -407,7 +406,7 @@ class UserView(
return Response("Verification code is not correct", status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["put"])
def forget_number(self, request, pk):
def forget_number(self, request, pk) -> Response:
target_user = self.get_object()
prev_state = target_user.insight_logs_serialized
@ -426,7 +425,7 @@ class UserView(
return Response(status=status.HTTP_200_OK)
@action(detail=True, methods=["post"], throttle_classes=[TestCallThrottler])
def make_test_call(self, request, pk):
def make_test_call(self, request, pk) -> Response:
user = self.get_object()
try:
phone_backend = PhoneBackend()
@ -441,7 +440,7 @@ class UserView(
return Response(status=status.HTTP_200_OK)
@action(detail=True, methods=["post"], throttle_classes=[TestCallThrottler])
def send_test_sms(self, request, pk):
def send_test_sms(self, request, pk) -> Response:
user = self.get_object()
try:
phone_backend = PhoneBackend()
@ -456,7 +455,7 @@ class UserView(
return Response(status=status.HTTP_200_OK)
@action(detail=True, methods=["post"], throttle_classes=[TestPushThrottler])
def send_test_push(self, request, pk):
def send_test_push(self, request, pk) -> Response:
user = self.get_object()
critical = request.query_params.get("critical", "false") == "true"
@ -475,7 +474,7 @@ class UserView(
return Response(status=status.HTTP_200_OK)
@action(detail=True, methods=["get"])
def get_backend_verification_code(self, request, pk):
def get_backend_verification_code(self, request, pk) -> Response:
user = self.get_object()
backend_id = request.query_params.get("backend")
@ -487,7 +486,7 @@ class UserView(
return Response(code)
@action(detail=True, methods=["get"])
def get_telegram_verification_code(self, request, pk):
def get_telegram_verification_code(self, request, pk) -> Response:
user = self.get_object()
if not user.is_telegram_connected:
@ -511,7 +510,7 @@ class UserView(
)
@action(detail=True, methods=["post"])
def unlink_slack(self, request, pk):
def unlink_slack(self, request, pk) -> Response:
user = self.get_object()
user.slack_user_identity = None
user.save(update_fields=["slack_user_identity"])
@ -525,7 +524,7 @@ class UserView(
return Response(status=status.HTTP_200_OK)
@action(detail=True, methods=["post"])
def unlink_telegram(self, request, pk):
def unlink_telegram(self, request, pk) -> Response:
user = self.get_object()
TelegramToUserConnector = apps.get_model("telegram", "TelegramToUserConnector")
try:
@ -543,7 +542,7 @@ class UserView(
return Response(status=status.HTTP_200_OK)
@action(detail=True, methods=["post"])
def unlink_backend(self, request, pk):
def unlink_backend(self, request, pk) -> Response:
# TODO: insight logs support
user = self.get_object()
@ -566,7 +565,7 @@ class UserView(
return Response(status=status.HTTP_200_OK)
@action(detail=True, methods=["get"])
def upcoming_shifts(self, request, pk):
def upcoming_shifts(self, request, pk) -> Response:
user = self.get_object()
try:
days = int(request.query_params.get("days", UPCOMING_SHIFTS_DEFAULT_DAYS))
@ -604,7 +603,7 @@ class UserView(
return Response(upcoming, status=status.HTTP_200_OK)
@action(detail=True, methods=["get", "post", "delete"])
def export_token(self, request, pk):
def export_token(self, request, pk) -> Response | None:
user = self.get_object()
if self.request.method == "GET":
@ -645,7 +644,7 @@ class UserView(
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["get"])
def check_availability(self, request, pk):
def check_availability(self, request, pk) -> Response:
user = self.get_object()
warnings = check_user_availability(user=user, team=request.user.current_team)
return Response(data={"warnings": warnings}, status=status.HTTP_200_OK)

View file

@ -183,7 +183,7 @@ class Organization(MaintainableObject):
ACKNOWLEDGE_REMIND_10H,
) = range(5)
ACKNOWLEDGE_REMIND_CHOICES = (
(ACKNOWLEDGE_REMIND_NEVER, "Never remind about ack-ed incidents"),
(ACKNOWLEDGE_REMIND_NEVER, "Never remind"),
(ACKNOWLEDGE_REMIND_1H, "Remind every 1 hour"),
(ACKNOWLEDGE_REMIND_3H, "Remind every 3 hours"),
(ACKNOWLEDGE_REMIND_5H, "Remind every 5 hours"),

View file

@ -13,6 +13,6 @@
}
.step .select {
width: 250px !important;
width: 200px !important;
flex-shrink: 0;
}

View file

@ -62,6 +62,9 @@ export default function Alerts() {
const isChatOpsConnected = getIfChatOpsConnected(currentUser);
const isPhoneVerified = currentUser?.cloud_connection_status === 3 || currentUser?.verified_phone_number;
const isDefaultNotificationsSet = currentUser?.notification_chain_verbal.default;
const isImportantNotificationsSet = currentUser?.notification_chain_verbal.important;
if (!showSlackInstallAlert && !showBannerTeam() && !showMismatchWarning() && !showChannelWarnings()) {
return null;
}
@ -125,23 +128,18 @@ export default function Alerts() {
onRemove={getRemoveAlertHandler(AlertID.CONNECTIVITY_WARNING)}
className={cx('alert')}
severity="warning"
title="Notification Warning"
title="Notification Warning! Possible notification miss."
>
{
<>
{!isChatOpsConnected && (
<>
No messenger connected. Possible notification miss. Connect messenger(s) in{' '}
<PluginLink query={{ page: 'users', id: 'me' }}>User profile settings</PluginLink> to receive all
notifications.
</>
)}
{!isPhoneVerified && (
<>
Your phone number is not verified. You can change your configuration in{' '}
<PluginLink query={{ page: 'users', id: 'me' }}>User profile settings</PluginLink>
</>
)}
{!isDefaultNotificationsSet && <>Default notification chain is not set. </>}
{!isImportantNotificationsSet && <>Important notification chain is not set. </>}
{!isChatOpsConnected && <>No messenger connected for ChatOps. </>}
{!isPhoneVerified && <>Your phone number is not verified. </>}
<>
You can change your configuration in{' '}
<PluginLink query={{ page: 'users', id: 'me' }}>User profile settings</PluginLink>
</>
</>
}
</Alert>

View file

@ -1,10 +1,11 @@
import React, { useEffect, useState, useCallback } from 'react';
import { Modal } from '@grafana/ui';
import { HorizontalGroup, Modal } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import { useMediaQuery } from 'react-responsive';
import Avatar from 'components/Avatar/Avatar';
import { Tabs, TabsContent } from 'containers/UserSettings/parts';
import { User as UserType } from 'models/user/user.types';
import { AppFeature } from 'state/features';
@ -55,15 +56,15 @@ const UserSettings = observer(({ id, onHide, tab = UserSettingsTab.UserInfo }: U
isCurrent,
];
const title = (
<HorizontalGroup>
<Avatar className={cx('user-avatar')} size="large" src={storeUser.avatar} /> <h2>{storeUser.username}</h2>
</HorizontalGroup>
);
return (
<>
<Modal
title={`${storeUser.username}`}
className={cx('modal', 'modal-wide')}
isOpen
closeOnEscape={false}
onDismiss={onHide}
>
<Modal title={title} className={cx('modal', 'modal-wide')} isOpen closeOnEscape={false} onDismiss={onHide}>
<div className={cx('root')}>
<Tabs
onTabChange={onTabChange}

View file

@ -1,20 +1,15 @@
import React, { useEffect, useState } from 'react';
import { Button, Icon, Label, LoadingPlaceholder } from '@grafana/ui';
import cn from 'classnames/bind';
import { Alert, Button, HorizontalGroup, InlineField, Input, LoadingPlaceholder, Tooltip } from '@grafana/ui';
import CopyToClipboard from 'react-copy-to-clipboard';
import Text from 'components/Text/Text';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { User } from 'models/user/user.types';
import { useStore } from 'state/useStore';
import { openNotification } from 'utils';
import { UserActions } from 'utils/authorization';
import styles from './index.module.css';
const cx = cn.bind(styles);
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ICalConnectorProps {
id: User['pk'];
@ -54,69 +49,84 @@ const ICalConnector = (props: ICalConnectorProps) => {
await userStore.deleteiCalLink(id);
};
const isCurrentUser = id === store.userStore.currentUserPk;
return (
<div className={cx('user-item')}>
<Label>iCal link:</Label>
<Text type="secondary">
Secret iCal export link to add your assigned on call shifts to your calendar.
<br />
NOTE: We do not have control over when a client refreshes an imported calendar.
</Text>
<div className={cx('iCal-settings')}>
{iCalLoading ? (
<LoadingPlaceholder text="Loading..." />
) : (
<>
{isiCalLinkExisting ? (
<>
{showiCalLink !== undefined ? (
<>
<div className={cx('iCal-link-container')}>
<Icon name="exclamation-triangle" className={cx('warning-icon')} />{' '}
<Text type="warning">Make sure you copy it - you won't be able to access it again.</Text>
<div className={cx('iCal-link')}>{showiCalLink}</div>
</div>
<CopyToClipboard
text={showiCalLink}
onCopy={() => {
openNotification('iCal link is copied');
}}
>
<Button icon="copy" variant="secondary" className={cx('iCal-button')}>
Copy iCal link
</Button>
</CopyToClipboard>
</>
) : (
<>
<Text type="secondary">
In case you lost your iCal link you can revoke it and generate a new one.
</Text>
<WithPermissionControlTooltip userAction={UserActions.UserSettingsWrite}>
<Button
icon="trash-alt"
onClick={handleRevokeiCalLink}
className={cx('iCal-button')}
variant="destructive"
fill="outline"
<>
{iCalLoading ? (
<LoadingPlaceholder text="Loading..." />
) : (
<>
{isiCalLinkExisting ? (
<>
{showiCalLink !== undefined ? (
<>
<InlineField
label="iCal link"
labelWidth={12}
tooltip={'Secret iCal export link to add your assigned on call shifts to your calendar'}
>
<HorizontalGroup spacing="xs">
<Tooltip content={'In case you lost your iCal link you can revoke it and generate a new one.'}>
<Input disabled value={showiCalLink} />
</Tooltip>
<CopyToClipboard
text={showiCalLink}
onCopy={() => {
openNotification('iCal link is copied');
}}
>
Revoke iCal link
</Button>
</WithPermissionControlTooltip>
</>
)}
</>
) : (
<WithPermissionControlTooltip userAction={UserActions.UserSettingsWrite}>
<Button icon="plus" onClick={handleCreateiCalLink} className={cx('iCal-button')} variant="secondary">
Create iCal link
<Button icon="copy">Copy</Button>
</CopyToClipboard>
</HorizontalGroup>
</InlineField>
<Alert severity="warning" title="Make sure you copy it - you won't be able to access it again." />
</>
) : (
<>
<WithPermissionControlTooltip userAction={UserActions.UserSettingsWrite}>
<InlineField
label="iCal link"
labelWidth={12}
tooltip={'Secret iCal export link to add your assigned on call shifts to your calendar'}
>
<HorizontalGroup spacing="xs">
<Tooltip content={'In case you lost your iCal link you can revoke it and generate a new one.'}>
<Input value={'***'} />
</Tooltip>
<WithConfirm
title={
'Are you sure you want to revoke iCal link' + (!isCurrentUser ? ' for other user' : '')
}
confirmText="Revoke"
>
<Button icon="trash-alt" variant="destructive" onClick={handleRevokeiCalLink}>
Revoke
</Button>
</WithConfirm>
</HorizontalGroup>
</InlineField>
</WithPermissionControlTooltip>
</>
)}
</>
) : (
<WithPermissionControlTooltip userAction={UserActions.UserSettingsWrite}>
<InlineField
label="iCal link"
labelWidth={12}
tooltip={'Secret iCal export link to add your assigned on call shifts to your calendar'}
>
<Button onClick={handleCreateiCalLink} variant="secondary">
Create
</Button>
</WithPermissionControlTooltip>
)}
</>
)}
</div>
</div>
</InlineField>
</WithPermissionControlTooltip>
)}
</>
)}
<Alert title="Note: We do not have control over when a client refreshes an imported calendar." severity="info" />
</>
);
};

View file

@ -1,34 +1,39 @@
import React, { useCallback } from 'react';
import { Button, Label } from '@grafana/ui';
import cn from 'classnames/bind';
import { Button, InlineField } from '@grafana/ui';
import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
import styles from './index.module.css';
const cx = cn.bind(styles);
import { User } from 'models/user/user.types';
import { useStore } from 'state/useStore';
interface MobileAppConnectorProps {
id: User['pk'];
onTabChange: (tab: UserSettingsTab) => void;
}
const MobileAppConnector = (props: MobileAppConnectorProps) => {
const { onTabChange } = props;
const { onTabChange, id } = props;
const store = useStore();
const { userStore } = store;
const handleClickConfirmMobileAppButton = useCallback(() => {
onTabChange(UserSettingsTab.MobileAppConnection);
}, [onTabChange]);
const user = userStore.items[id];
const isCurrentUser = id === store.userStore.currentUserPk;
const isMobileAppConnected = user.messaging_backends['MOBILE_APP']?.connected;
return (
<div className={cx('user-item')}>
<Label>Mobile App:</Label>
<div>
<Button size="sm" fill="text" onClick={handleClickConfirmMobileAppButton}>
Click to add a mobile app
<InlineField label="Mobile App" labelWidth={12} disabled={!isCurrentUser}>
{isMobileAppConnected ? (
<Button variant="destructive" onClick={handleClickConfirmMobileAppButton}>
Disconnect
</Button>
</div>
</div>
) : (
<Button onClick={handleClickConfirmMobileAppButton}>Connect</Button>
)}
</InlineField>
);
};

View file

@ -1,18 +1,13 @@
import React, { useCallback } from 'react';
import { Button, Label, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import { Alert, Button, HorizontalGroup, InlineField, Input } from '@grafana/ui';
import Text from 'components/Text/Text';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
import { User } from 'models/user/user.types';
import { AppFeature } from 'state/features';
import { useStore } from 'state/useStore';
import styles from './index.module.css';
const cx = cn.bind(styles);
interface PhoneConnectorProps {
id: User['pk'];
onTabChange: (tab: UserSettingsTab) => void;
@ -30,82 +25,129 @@ const PhoneConnector = (props: PhoneConnectorProps) => {
onTabChange(UserSettingsTab.PhoneVerification);
}, [storeUser?.unverified_phone_number, onTabChange]);
const isCurrentUser = storeUser.pk === userStore.currentUserPk;
const cloudVersionPhone = (user: User) => {
switch (user.cloud_connection_status) {
case 0:
return <Text className={cx('error-message')}>Cloud is not synced</Text>;
return (
<>
<InlineField
label="Phone"
labelWidth={12}
tooltip={'OnCall uses Grafana Cloud for SMS and phone call notifications'}
>
<Button onClick={handleClickConfirmPhoneButton}>Connect to Cloud</Button>
</InlineField>
<Alert title="This instance is not connected to Cloud OnCall" severity="warning" />
</>
);
case 1:
return (
<VerticalGroup>
<Text className={cx('error-message')}>User is not matched with cloud</Text>
<Button size="sm" fill="text" onClick={handleClickConfirmPhoneButton}>
Sign Up to Cloud
</Button>
</VerticalGroup>
<>
<InlineField
label="Phone"
labelWidth={12}
tooltip={'OnCall uses Grafana Cloud for SMS and phone call notifications'}
>
<Button onClick={handleClickConfirmPhoneButton}>Reload from Cloud</Button>
</InlineField>
<Alert title="User is not matched with cloud" severity="warning" />
</>
);
case 2:
return (
<VerticalGroup>
<Text type="warning">Phone number is not verified in Grafana Cloud</Text>
<Button size="sm" fill="text" onClick={handleClickConfirmPhoneButton}>
Verify or change
</Button>
</VerticalGroup>
<>
<InlineField
label="Phone"
labelWidth={12}
tooltip={'OnCall uses Grafana Cloud for SMS and phone call notifications'}
>
<Button onClick={handleClickConfirmPhoneButton}>Verify in Cloud</Button>
</InlineField>
<Alert title="Phone number is not verified in Grafana Cloud" severity="warning" />
</>
);
case 3:
return (
<VerticalGroup>
<Text type="success">Phone number verified</Text>
<Button size="sm" fill="text" onClick={handleClickConfirmPhoneButton}>
Change
</Button>
</VerticalGroup>
<>
<InlineField
label="Phone"
labelWidth={12}
tooltip={'OnCall uses Grafana Cloud for SMS and phone call notifications'}
>
<Button onClick={handleClickConfirmPhoneButton}>Change in Cloud</Button>
</InlineField>
<Alert title="Phone number verified" severity="success" />
</>
);
default:
return (
<VerticalGroup>
<Text className={cx('error-message')}>User is not matched with cloud</Text>
<Button size="sm" fill="text" onClick={handleClickConfirmPhoneButton}>
Sign Up to Cloud
</Button>
</VerticalGroup>
<>
<InlineField
label="Phone"
disabled={true}
labelWidth={12}
tooltip={'OnCall uses Grafana Cloud for SMS and phone call notifications'}
>
<Button onClick={handleClickConfirmPhoneButton}>Reload from Cloud</Button>
</InlineField>
<Alert title="User is not matched with cloud" severity="warning" />
</>
);
}
};
return (
<div className={cx('user-item')}>
<div>
{store.hasFeature(AppFeature.CloudNotifications) ? (
<>
<Label>Cloud phone status:</Label>
{cloudVersionPhone(storeUser)}
</>
<>{cloudVersionPhone(storeUser)}</>
) : (
<>
<Label>Verified phone number:</Label>
<span className={cx('user-value')}>{storeUser.verified_phone_number || '—'}</span>
{storeUser.verified_phone_number ? (
<div>
<Text type="secondary">Phone number is verified</Text>
<Button size="sm" fill="text" onClick={handleClickConfirmPhoneButton}>
Change
</Button>
<InlineField label="Phone" labelWidth={12}>
<HorizontalGroup spacing="xs">
<Input disabled={true} value={storeUser.verified_phone_number} />
{isCurrentUser ? (
<Button variant="secondary" icon="edit" onClick={handleClickConfirmPhoneButton} />
) : (
<WithConfirm title="Are you sure you want to edit other's phone number?" confirmText="Proceed">
<Button variant="secondary" icon="edit" onClick={handleClickConfirmPhoneButton} />
</WithConfirm>
)}
</HorizontalGroup>
</InlineField>
</div>
) : storeUser.unverified_phone_number ? (
<div>
<Text type="warning">Phone number is not verified</Text>
<Button size="sm" fill="text" onClick={handleClickConfirmPhoneButton}>
Verify or change
</Button>
<InlineField label="Phone" labelWidth={12}>
<HorizontalGroup spacing="xs">
<Input disabled={true} value={storeUser.unverified_phone_number} />
{isCurrentUser ? (
<Button onClick={handleClickConfirmPhoneButton}>Verify</Button>
) : (
<WithConfirm title="Are you sure you want to verify other's phone number?" confirmText="Proceed">
<Button onClick={handleClickConfirmPhoneButton}>Verify</Button>
</WithConfirm>
)}
</HorizontalGroup>
</InlineField>
<Alert title="Phone number is not verified. Verify or change" severity="warning" />
</div>
) : (
<div>
<Text type="warning">Phone number is not added</Text>
<Button size="sm" fill="text" onClick={handleClickConfirmPhoneButton}>
Add
</Button>
<InlineField label="Phone" labelWidth={12}>
{isCurrentUser ? (
<Button onClick={handleClickConfirmPhoneButton}>Add phone number</Button>
) : (
<WithConfirm title="Are you sure you want to add other's phone number?" confirmText="Proceed">
<Button onClick={handleClickConfirmPhoneButton}>Add phone number</Button>
</WithConfirm>
)}
</InlineField>
</div>
)}
</>

View file

@ -1,18 +1,12 @@
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import { Button, Label } from '@grafana/ui';
import cn from 'classnames/bind';
import { Button, HorizontalGroup, InlineField, Input } from '@grafana/ui';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
import { User } from 'models/user/user.types';
import { useStore } from 'state/useStore';
import styles from './index.module.css';
const cx = cn.bind(styles);
import { getPathFromQueryParams } from 'utils/url';
interface SlackConnectorProps {
id: User['pk'];
@ -27,7 +21,7 @@ const SlackConnector = (props: SlackConnectorProps) => {
const storeUser = userStore.items[id];
const isCurrent = id === store.userStore.currentUserPk;
const isCurrentUser = id === store.userStore.currentUserPk;
const handleConnectButtonClick = useCallback(() => {
onTabChange(UserSettingsTab.SlackInfo);
@ -37,43 +31,67 @@ const SlackConnector = (props: SlackConnectorProps) => {
userStore.unlinkSlack(userStore.currentUserPk);
}, []);
const chatOpsQuery = { page: 'chat-ops' };
const chatOpsPath = useMemo(() => getPathFromQueryParams(chatOpsQuery), [chatOpsQuery]);
return (
<div className={cx('user-item')}>
<Label>Slack username:</Label>
<span className={cx('user-value')}>{storeUser.slack_user_identity?.name || '—'}</span>
<>
{storeUser.slack_user_identity ? (
<div>
<Text type="secondary"> Slack account is connected</Text>
{storeUser.pk === userStore.currentUserPk ? (
<WithConfirm title="Are you sure to disconnect your Slack account?" confirmText="Disconnect">
<Button size="sm" fill="text" variant="destructive" onClick={handleUnlinkSlackAccount}>
Unlink Slack account
<>
<InlineField
label="Slack"
labelWidth={12}
tooltip={'Connected Slack user will receive mentions during escalations'}
>
<HorizontalGroup spacing="xs">
<Input
disabled={true}
value={
storeUser.slack_user_identity?.slack_login ? '@' + storeUser.slack_user_identity?.slack_login : ''
}
/>
<WithConfirm title="Are you sure to disconnect your Slack account?" confirmText="Disconnect">
<Button
variant="destructive"
icon="times"
onClick={handleUnlinkSlackAccount}
disabled={!isCurrentUser}
/>
</WithConfirm>
</HorizontalGroup>
</InlineField>
</>
) : teamStore.currentTeam?.slack_team_identity ? (
<>
<InlineField
label="Slack"
labelWidth={12}
disabled={!isCurrentUser}
tooltip={'To receive mentions for alert groups posted on Slack, connect your Slack profile.'}
>
<Button onClick={handleConnectButtonClick}>Connect account</Button>
</InlineField>
</>
) : (
<>
<InlineField
label="Slack"
labelWidth={12}
tooltip={'To receive mentions for alert groups posted on Slack, connect your Slack profile.'}
>
<WithConfirm
title="Leave personal profile settings?"
confirmText="Continue"
description="OnCall Slack Application is not installed globally for this Grafana Organization Stack. Please install it in Organization Settings before connecting personal Slack Profile."
>
<Button onClick={() => window.open(chatOpsPath, '_blank')} icon="external-link-alt">
Install Slack App
</Button>
</WithConfirm>
) : (
''
)}
</div>
) : teamStore.currentTeam?.slack_team_identity ? (
<div>
<Text type="warning">Slack account is not connected</Text>
{isCurrent && (
<Button size="sm" fill="text" onClick={handleConnectButtonClick}>
Connect
</Button>
)}
</div>
) : (
<div>
<Text type="warning">Slack Integration is not installed</Text>
<PluginLink query={{ page: 'chat-ops' }}>
<Button size="sm" fill="text">
Install
</Button>
</PluginLink>
</div>
</InlineField>
</>
)}
</div>
</>
);
};

View file

@ -1,18 +1,12 @@
import React, { useCallback } from 'react';
import { Button, Label } from '@grafana/ui';
import cn from 'classnames/bind';
import { Button, HorizontalGroup, InlineField, Input } from '@grafana/ui';
import Text from 'components/Text/Text';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
import { User } from 'models/user/user.types';
import { useStore } from 'state/useStore';
import styles from './index.module.css';
const cx = cn.bind(styles);
interface TelegramConnectorProps {
id: User['pk'];
onTabChange: (tab: UserSettingsTab) => void;
@ -26,7 +20,7 @@ const TelegramConnector = (props: TelegramConnectorProps) => {
const storeUser = userStore.items[id];
const isCurrent = id === store.userStore.currentUserPk;
const isCurrentUser = id === store.userStore.currentUserPk;
const handleConnectButtonClick = useCallback(() => {
onTabChange(UserSettingsTab.TelegramInfo);
@ -37,32 +31,31 @@ const TelegramConnector = (props: TelegramConnectorProps) => {
}, []);
return (
<div className={cx('user-item')}>
<Label>Telegram username:</Label>
<span className={cx('user-value')}>{storeUser.telegram_configuration?.telegram_nick_name || '—'}</span>
{storeUser.telegram_configuration && storeUser.pk === userStore.currentUserPk ? (
<div>
<Text type="secondary"> Telegram account is connected</Text>
{storeUser.pk === userStore.currentUserPk ? (
<div>
<InlineField label="Telegram" labelWidth={12} disabled={!isCurrentUser}>
{storeUser.telegram_configuration ? (
<HorizontalGroup spacing="xs">
<Input
disabled={true}
value={
storeUser.telegram_configuration?.telegram_nick_name
? '@' + storeUser.telegram_configuration?.telegram_nick_name
: ''
}
/>
<WithConfirm title="Are you sure to disconnect your Telegram account?" confirmText="Disconnect">
<Button size="sm" fill="text" variant="destructive" onClick={handleUnlinkTelegramAccount}>
Unlink Telegram account
</Button>
<Button
onClick={handleUnlinkTelegramAccount}
variant="destructive"
icon="times"
disabled={!isCurrentUser}
/>
</WithConfirm>
) : (
''
)}
</div>
) : (
<div>
<Text type="warning">Telegram account is not connected</Text>
{isCurrent && (
<Button size="sm" fill="text" onClick={handleConnectButtonClick}>
Connect
</Button>
)}
</div>
)}
</HorizontalGroup>
) : (
<Button onClick={handleConnectButtonClick}>Connect account</Button>
)}
</InlineField>
</div>
);
};

View file

@ -1,5 +1,7 @@
import React, { FC } from 'react';
import { Legend } from '@grafana/ui';
import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
import { User } from 'models/user/user.types';
import { AppFeature } from 'state/features';
@ -19,12 +21,13 @@ interface ConnectorsProps {
export const Connectors: FC<ConnectorsProps> = (props) => {
const store = useStore();
return (
<div>
<>
<PhoneConnector {...props} />
<MobileAppConnector {...props} />
<SlackConnector {...props} />
{store.hasFeature(AppFeature.Telegram) && <TelegramConnector {...props} />}
<Legend>Calendar export</Legend>
<ICalConnector {...props} />
<MobileAppConnector {...props} />
</div>
</>
);
};

View file

@ -116,10 +116,10 @@ export const TabsContent = observer(({ id, activeTab, onTabChange, isDesktopOrLa
{activeTab === UserSettingsTab.UserInfo &&
(isDesktopOrLaptop ? (
<div className={cx('columns')}>
<Block shadowed bordered style={{ width: '30%' }} className={cx('col', 'left')}>
<Block shadowed bordered style={{ width: '40%' }} className={cx('col', 'left')}>
<UserInfoTab id={id} onTabChange={onTabChange} />
</Block>
<Block shadowed bordered style={{ width: '70%' }} className={cx('col', 'right')}>
<Block shadowed bordered style={{ width: '60%' }} className={cx('col', 'right')}>
<NotificationSettingsTab id={id} />
</Block>
</div>

View file

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Button, HorizontalGroup, VerticalGroup, LoadingPlaceholder } from '@grafana/ui';
import { Button, VerticalGroup, LoadingPlaceholder } from '@grafana/ui';
import { observer } from 'mobx-react';
import PluginLink from 'components/PluginLink/PluginLink';
@ -23,6 +23,8 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => {
const [userStatus, setUserStatus] = useState<number>(0);
const [userLink, setUserLink] = useState<string>(null);
const email = store.userStore.items[userPk].email;
useEffect(() => {
getCloudUserInfo();
}, []);
@ -63,9 +65,8 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => {
return (
<VerticalGroup spacing="lg">
<Text>
{
'We cant find a matching account in the connected Grafana Cloud instance (matching happens by e-mail). '
}
We cant find a matching account in the connected Grafana Cloud instance (matching by e-mail
{email && ': ' + email}).
</Text>
<Button variant="primary" onClick={() => handleLinkClick(userLink)}>
Sign up in Grafana Cloud
@ -98,9 +99,8 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => {
return (
<VerticalGroup spacing="lg">
<Text>
{
'We cant find a matching account in the connected Grafana Cloud instance (matching happens by e-mail). '
}
We cant find a matching account in the connected Grafana Cloud instance (matching by e-mail
{email && ': ' + email}).
</Text>
<Button variant="primary" onClick={() => handleLinkClick(userLink)}>
Sign up in Grafana Cloud
@ -118,18 +118,16 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => {
message="You do not have permission to perform this action. Ask an admin to upgrade your permissions."
>
<VerticalGroup spacing="lg">
<HorizontalGroup justify="space-between">
<Text.Title level={3}>OnCall uses Grafana Cloud for SMS and phone call notifications</Text.Title>
{syncing ? (
<Button variant="secondary" icon="sync" disabled>
Updating...
</Button>
) : (
<Button variant="secondary" icon="sync" onClick={syncUser} disabled={userStatus === 0}>
Update
</Button>
)}
</HorizontalGroup>
<Text.Title level={3}>OnCall uses Grafana Cloud for SMS and phone call notifications</Text.Title>
{syncing ? (
<Button icon="sync" variant="secondary" disabled>
Updating...
</Button>
) : (
<Button icon="sync" variant="secondary" onClick={syncUser} disabled={userStatus === 0}>
Reload from Cloud
</Button>
)}
{!syncing ? <UserCloudStatus /> : <LoadingPlaceholder text="Loading..." />}
</VerticalGroup>
</WithPermissionControlDisplay>

View file

@ -1,18 +1,12 @@
import React from 'react';
import { Label } from '@grafana/ui';
import cn from 'classnames/bind';
import { InlineField, Input, Legend } from '@grafana/ui';
import Text from 'components/Text/Text';
import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
import { Connectors } from 'containers/UserSettings/parts/connectors';
import { User } from 'models/user/user.types';
import { useStore } from 'state/useStore';
import styles from './UserInfoTab.module.css';
const cx = cn.bind(styles);
interface UserInfoTabProps {
id: User['pk'];
onTabChange: (tab: UserSettingsTab) => void;
@ -25,23 +19,27 @@ export const UserInfoTab = (props: UserInfoTabProps) => {
const { userStore } = store;
const storeUser = userStore.items[id];
let width = 12;
return (
<>
<div className={cx('user-item')}>
<Text type="secondary">
To edit user details such as Username, email, and roles, please visit{' '}
<a href="/org/users"> Grafana User settings</a>.
</Text>
</div>
<div className={cx('user-item')}>
<Label>Username:</Label>
<span className={cx('user-value')}>{storeUser.username || '—'}</span>
</div>
<div className={cx('user-item')}>
<Label>Email:</Label>
<span className={cx('user-value')}>{storeUser.email || '—'}</span>
</div>
<Legend>User information</Legend>
<InlineField
label="Username"
labelWidth={width}
grow
disabled
tooltip="To edit username go to Grafana user management"
>
<Input value={storeUser.username || ''} />
</InlineField>
<InlineField label="Email" labelWidth={width} grow disabled tooltip="To edit email go to Grafana user management">
<Input value={storeUser.email || ''} />
</InlineField>
<InlineField label="Timezone" labelWidth={width} grow disabled>
<Input value={storeUser.timezone || ''} />
</InlineField>
<Legend>Notification channels</Legend>
<Connectors {...props} />
</>
);

View file

@ -1,6 +1,16 @@
import React, { Component } from 'react';
import { Alert, Field, HorizontalGroup, LoadingPlaceholder, VerticalGroup, Icon, Button } from '@grafana/ui';
import {
Alert,
HorizontalGroup,
LoadingPlaceholder,
VerticalGroup,
Icon,
Button,
InlineField,
Input,
Legend,
} from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
@ -98,95 +108,87 @@ class SlackSettings extends Component<SlackProps, SlackState> {
return (
<div className={cx('root')}>
<div className={cx('title')}>
<Text.Title level={3}>Slack</Text.Title>
</div>
<div className={cx('slack-settings')}>
<HorizontalGroup justify="space-between">
<HorizontalGroup align="center">
<Field label="Slack Workspace">
<div className={cx('select', 'control', 'team_workspace')}>
<Text>{store.teamStore.currentTeam.slack_team_identity?.cached_name}</Text>
</div>
</Field>
<Field
label="Default channel for Slack notifications"
description="The selected channel will be used as a fallback in the event that a schedule or integration does not have a configured channel"
>
<WithPermissionControlTooltip userAction={UserActions.ChatOpsUpdateSettings}>
<GSelect
showSearch
className={cx('select', 'control')}
modelName="slackChannelStore"
displayField="display_name"
valueField="id"
placeholder="Select Slack Channel"
value={teamStore.currentTeam?.slack_channel?.id}
onChange={this.handleSlackChannelChange}
nullItemName={PRIVATE_CHANNEL_NAME}
/>
</WithPermissionControlTooltip>
</Field>
</HorizontalGroup>
<WithPermissionControlTooltip userAction={UserActions.ChatOpsUpdateSettings}>
<WithConfirm
title="Remove Slack Integration for all of OnCall"
description={
<Alert severity="error" title="WARNING">
<p>Are you sure to delete this Slack Integration?</p>
<p>
Removing the integration will also irreverisbly remove the following data for your OnCall plugin:
</p>
<ul style={{ marginLeft: '20px' }}>
<li>default organization Slack channel</li>
<li>default Slack channels for OnCall Integrations</li>
<li>Slack channels & Slack user groups for OnCall Schedules</li>
<li>linked Slack usernames for OnCall Users</li>
</ul>
<br />
<p>
If you would like to instead remove your linked Slack username, please head{' '}
<PluginLink query={{ page: 'users/me' }}>here</PluginLink>.
</p>
</Alert>
}
confirmationText="DELETE"
>
<Button variant="destructive" size="sm" onClick={() => this.removeSlackIntegration()}>
Disconnect
</Button>
</WithConfirm>
<Legend>Slack App settings</Legend>
<InlineField label="Slack Workspace" grow disabled>
<Input value={store.teamStore.currentTeam.slack_team_identity?.cached_name} />
</InlineField>
<InlineField
label="Default channel for Slack notifications"
tooltip="The selected channel will be used as a fallback in the event that a schedule or integration does not have a configured channel"
>
<WithPermissionControlTooltip userAction={UserActions.ChatOpsUpdateSettings}>
<GSelect
showSearch
modelName="slackChannelStore"
displayField="display_name"
valueField="id"
placeholder="Select Slack Channel"
value={teamStore.currentTeam?.slack_channel?.id}
onChange={this.handleSlackChannelChange}
nullItemName={PRIVATE_CHANNEL_NAME}
/>
</WithPermissionControlTooltip>
</InlineField>
<Alert
severity="info"
title="Tip: Create a separate channel for OnCall Slack App notifications (catch-all). Avoid using #general, etc."
/>
<InlineField>
<WithPermissionControlTooltip userAction={UserActions.ChatOpsUpdateSettings}>
<WithConfirm
title="Remove Slack Integration for all of OnCall"
description={
<Alert severity="error" title="WARNING">
<p>Are you sure to delete this Slack Integration?</p>
<p>
Removing the integration will also irreverisbly remove the following data for your OnCall plugin:
</p>
<ul style={{ marginLeft: '20px' }}>
<li>default organization Slack channel</li>
<li>default Slack channels for OnCall Integrations</li>
<li>Slack channels & Slack user groups for OnCall Schedules</li>
<li>linked Slack usernames for OnCall Users</li>
</ul>
<br />
<p>
If you would like to instead remove your linked Slack username, please head{' '}
<PluginLink query={{ page: 'users/me' }}>here</PluginLink>.
</p>
</Alert>
}
confirmationText="DELETE"
>
<Button variant="destructive" onClick={() => this.removeSlackIntegration()}>
Disconnect Slack App
</Button>
</WithConfirm>
</WithPermissionControlTooltip>
</InlineField>
<Legend>Additional settings</Legend>
<InlineField
label="Timeout for acknowledged alerts"
tooltip="Slack app will send reminders into alert group slack thread and unacknowledge alert group if no confirmation is received."
>
<HorizontalGroup spacing="xs">
<WithPermissionControlTooltip userAction={UserActions.ChatOpsWrite}>
<RemoteSelect
showSearch={false}
href={'/slack_settings/acknowledge_remind_options/'}
value={slackStore.slackSettings?.acknowledge_remind_timeout}
onChange={this.getSlackSettingsChangeHandler('acknowledge_remind_timeout')}
/>
</WithPermissionControlTooltip>
<WithPermissionControlTooltip userAction={UserActions.ChatOpsWrite}>
<RemoteSelect
disabled={slackStore.slackSettings?.acknowledge_remind_timeout === 0}
showSearch={false}
href={'/slack_settings/unacknowledge_timeout_options/'}
value={slackStore.slackSettings?.unacknowledge_timeout}
onChange={this.getSlackSettingsChangeHandler('unacknowledge_timeout')}
/>
</WithPermissionControlTooltip>
</HorizontalGroup>
</div>
<div className={cx('slack-settings')}>
<Text.Title level={5} className={cx('title')}>
Additional settings
</Text.Title>
<Field label="Timeout for acknowledged alerts">
<HorizontalGroup>
<WithPermissionControlTooltip userAction={UserActions.ChatOpsWrite}>
<RemoteSelect
className={cx('select')}
showSearch={false}
href={'/slack_settings/acknowledge_remind_options/'}
value={slackStore.slackSettings?.acknowledge_remind_timeout}
onChange={this.getSlackSettingsChangeHandler('acknowledge_remind_timeout')}
/>
</WithPermissionControlTooltip>
<WithPermissionControlTooltip userAction={UserActions.ChatOpsWrite}>
<RemoteSelect
className={cx('select')}
disabled={slackStore.slackSettings?.acknowledge_remind_timeout === 0}
showSearch={false}
href={'/slack_settings/unacknowledge_timeout_options/'}
value={slackStore.slackSettings?.unacknowledge_timeout}
onChange={this.getSlackSettingsChangeHandler('unacknowledge_timeout')}
/>
</WithPermissionControlTooltip>
</HorizontalGroup>
</Field>
</div>
</InlineField>
</div>
);
};
@ -257,7 +259,7 @@ class SlackSettings extends Component<SlackProps, SlackState> {
<SlackNewIcon />
</div>
<Text className={cx('infoblock-text')}>
Slack connection will allow you to manage alert groups in your team Slack workspace.
Connecting Slack App will allow you to manage alert groups in your team Slack workspace.
</Text>
<Text className={cx('infoblock-text')}>
After a basic workspace connection your team members need to connect their personal Slack accounts in

View file

@ -141,7 +141,7 @@ class Users extends React.Component<UsersProps, UsersState> {
width: '20%',
title: 'Status',
key: 'note',
render: this.renderNote,
render: this.renderStatus,
},
{
width: '20%',
@ -323,54 +323,68 @@ class Users extends React.Component<UsersProps, UsersState> {
);
};
renderNote = (user: UserType) => {
renderStatus = (user: UserType) => {
const { store } = this.props;
if (user.hidden_fields === true) {
return null;
}
let phone_verified = user.verified_phone_number !== null;
let phone_not_verified_message = 'Phone not verified';
let warnings = [];
// Show warnining if no notifications are set
if (!this.renderNotificationsChain(user)) {
warnings.push('No Default Notifications');
}
if (!this.renderImportantNotificationsChain(user)) {
warnings.push('No Important Notifications');
}
let phone_verified = user.verified_phone_number !== null;
if (user.cloud_connection_status !== null) {
phone_verified = false;
switch (user.cloud_connection_status) {
case 0:
phone_not_verified_message = 'Cloud is not synced';
// Cloud is not connected, no need to show warning to the user
break;
case 1:
phone_not_verified_message = 'User not matched with cloud';
warnings.push('User not matched with cloud');
break;
case 2:
phone_not_verified_message = 'Phone number is not verified in Grafana Cloud';
warnings.push('Phone number is not verified in Grafana Cloud');
break;
case 3:
// Phone is verified in Grafana Cloud, no need to show warning to the user
phone_verified = true;
break;
}
} else {
if (!phone_verified) {
warnings.push('Phone not verified');
}
}
if (!phone_verified || !user.slack_user_identity || !user.telegram_configuration) {
let texts = [];
if (!phone_verified) {
texts.push(phone_not_verified_message);
}
if (!user.slack_user_identity) {
texts.push('Slack not verified');
}
if (store.hasFeature(AppFeature.Telegram) && !user.telegram_configuration) {
texts.push('Telegram not verified');
}
if (store.teamStore.currentTeam.slack_team_identity && !user.slack_user_identity) {
warnings.push('Slack profile is not connected');
}
return (
let telegramChannelsExist = store.telegramChannelStore.currentTeamToTelegramChannel?.length > 0;
if (store.hasFeature(AppFeature.Telegram) && telegramChannelsExist && !user.telegram_configuration) {
warnings.push('Telegram profile is not connected');
}
return (
warnings.length > 0 && (
<HorizontalGroup>
<TooltipBadge
borderType="warning"
icon="exclamation-triangle"
text={texts.length}
text={warnings.length}
tooltipTitle="Warnings"
tooltipContent={
<VerticalGroup spacing="none">
{texts.map((warning, index) => (
{warnings.map((warning, index) => (
<Text type="primary" key={index}>
{warning}
</Text>
@ -379,10 +393,8 @@ class Users extends React.Component<UsersProps, UsersState> {
}
/>
</HorizontalGroup>
);
}
return 'All contacts verified';
)
);
};
debouncedUpdateUsers = debounce(this.updateUsers, 500);