Merge pull request #1254 from grafana/dev

dev -> main for release
This commit is contained in:
Joey Orlando 2023-01-30 14:25:28 +01:00 committed by GitHub
commit 37fa91697e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 541 additions and 227 deletions

3
.github/CODEOWNERS vendored
View file

@ -1,3 +1,6 @@
* @grafana/grafana-oncall-backend
# don't tag @grafana/grafana-oncall-backend on changes to CHANGELOG.md
CHANGELOG.md
/grafana-plugin @grafana/grafana-oncall-frontend

View file

@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pycqa/isort
rev: 5.9.3
rev: 5.12.0
hooks:
- id: isort
files: ^engine

View file

@ -5,7 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
## v1.1.20 (2023-01-30)
### Added
- Add involved users filter to alert groups listing page (+ mine shortcut)
### Changed
@ -14,11 +18,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Fix bugs related to creating contact point for Grafana Alerting integration
- Fix minor UI bug on OnCall users page where it would idefinitely show a "Loading..." message
- Only show OnCall user's table to users that are authorized
- Fixed NPE in ScheduleUserDetails component ([#1229](https://github.com/grafana/oncall/issues/1229))
## v1.1.19 (2023-01-25)
### Added
- Add Server URL below QR code for OSS for debugging purposes
- Add Slack slash command allowing to trigger a direct page via a manually created alert group
- Remove resolved and acknowledged filters as we switched to status ([#1201](https://github.com/grafana/oncall/pull/1201))
- Add sync with grafana on /users and /teams api calls from terraform plugin

View file

@ -1,7 +1,7 @@
{
"extends": "../.markdownlint.json",
"MD013": {
"line_length": "140"
"line_length": "160"
},
"MD025": false,
"MD036": false

View file

@ -81,3 +81,26 @@ teams of their on-call shifts. Admins can configure shift notification behavior
1. When an on-call shift notification is sent to a person or channel, click the gear icon to
access **Notifications preferences**.
2. Configure on-call notifications for future shift notifications.
## Slack commands and message shortcuts
The Grafana OnCall Slack app includes helpful message shortcuts and slash commands.
### Slack commands
Use the `/oncall` Slack command to create a new alert group directly from Slack.
1. Type `/oncall` in the message box of the desired Slack channel then click **Send**.
1. Fill out the **Start New Escalation** creation form then click **Submit**.
1. Once the Grafana OnCall app sends a Slack message with the newly created alert, the alert group is open and firing.
### Message shortcuts
Use message shortcuts to add resolution notes directly from Slack. Message shortcuts are available in the More actions menu from any message.
>**Note:** In order to associate the resolution note to an alert group, this message shortcut can only be applied to messages in the thread of an alert group.
1. From an alert group thread, navigate to the Slack message that you wish to add as a resolution note.
1. Hover over the message and select **More actions** from the menu options.
1. Select **Add as resolution note**.
1. The Grafana OnCall app will react to the message in Slack with the memo emoji and add the message to the alert group timeline.

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.16 on 2023-01-27 07:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('alerts', '0008_alter_alertgrouplogrecord_type'),
]
operations = [
migrations.AddField(
model_name='alertreceivechannel',
name='web_templates_modified_at',
field=models.DateTimeField(blank=True, null=True),
),
]

View file

@ -160,6 +160,7 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
web_title_template = models.TextField(null=True, default=None)
web_message_template = models.TextField(null=True, default=None)
web_image_url_template = models.TextField(null=True, default=None)
web_templates_modified_at = models.DateTimeField(blank=True, null=True)
# email related fields are deprecated in favour of messaging backend based templates
# these templates are stored in the messaging_backends_templates field

View file

@ -1,4 +1,4 @@
from typing import Any
from typing import Any, Optional
from uuid import uuid4
from django.db import transaction
@ -142,7 +142,7 @@ def direct_paging(
schedules: ScheduleNotifications = None,
escalation_chain: EscalationChain = None,
alert_group: AlertGroup = None,
) -> None:
) -> Optional[AlertGroup]:
"""Trigger escalation targeting given users/schedules.
If an alert group is given, update escalation to include the specified users.
@ -185,6 +185,8 @@ def direct_paging(
)
notify_user_task.apply_async((u.pk, alert_group.pk), {"important": important})
return alert_group
def unpage_user(alert_group: AlertGroup, user: User, from_user: User) -> None:
"""Remove user from alert group escalation."""

View file

@ -279,6 +279,19 @@ def test_direct_paging_custom_chain(
assert ag.escalation_chain_with_respect_to_escalation_snapshot == custom_chain
@pytest.mark.django_db
def test_direct_paging_returns_alert_group(make_organization, make_user_for_organization):
organization = make_organization()
user = make_user_for_organization(organization)
from_user = make_user_for_organization(organization)
with patch("apps.alerts.paging.notify_user_task"):
alert_group = direct_paging(organization, None, from_user, title="Help!", message="Fire", users=[(user, False)])
# check alert group returned by direct paging is the same as the one created
assert alert_group == AlertGroup.all_objects.get()
@pytest.mark.django_db
def test_unpage_user_not_exists(
make_organization, make_user_for_organization, make_alert_receive_channel, make_alert_group

View file

@ -1,5 +1,7 @@
import logging
from django.core.cache import cache
from django.utils import timezone
from rest_framework import serializers
from apps.alerts.incident_appearance.renderers.classic_markdown_renderer import AlertGroupClassicMarkdownRenderer
@ -13,6 +15,7 @@ from .alert_receive_channel import FastAlertReceiveChannelSerializer
from .user import FastUserSerializer
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
class ShortAlertGroupSerializer(serializers.ModelSerializer):
@ -92,10 +95,33 @@ class AlertGroupListSerializer(EagerLoadingMixin, serializers.ModelSerializer):
if not obj.last_alert:
return {}
return AlertGroupWebRenderer(obj, obj.last_alert).render()
web_templates_modified_at = obj.channel.web_templates_modified_at
last_alert_created_at = obj.last_alert.created_at
CACHE_KEY = f"render_for_web_alert_group_{obj.id}"
CACHE_LIFEIME = 60 * 60 * 24
cached_render_for_web = cache.get(CACHE_KEY, None)
# use cache only if cache exists
# and cache was created after the last alert created
# and either web templates never modified
# or cache was created after templates were modified
if (
cached_render_for_web is not None
and cached_render_for_web.get("cache_created_at") > last_alert_created_at
and (
web_templates_modified_at is None
or cached_render_for_web.get("cache_created_at") > web_templates_modified_at
)
):
render_for_web = cached_render_for_web.get("render_for_web")
else:
render_for_web = AlertGroupWebRenderer(obj, obj.last_alert).render()
cache.set(CACHE_KEY, {"cache_created_at": timezone.now(), "render_for_web": render_for_web}, CACHE_LIFEIME)
return render_for_web
def get_render_for_classic_markdown(self, obj):
# alert group has no alerts
if not obj.last_alert:
return {}

View file

@ -7,6 +7,7 @@ from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.validators import URLValidator
from django.template.loader import render_to_string
from django.utils import timezone
from jinja2 import TemplateSyntaxError
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
@ -379,6 +380,7 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode
self.instance.web_title_template = value.strip()
elif default_template is not None and default_template.strip() == value.strip():
self.instance.web_title_template = None
self.instance.web_templates_modified_at = timezone.now()
def get_web_message_template(self, obj):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_WEB_MESSAGE_TEMPLATE[obj.integration]
@ -390,6 +392,7 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode
self.instance.web_message_template = value.strip()
elif default_template is not None and default_template.strip() == value.strip():
self.instance.web_message_template = None
self.instance.web_templates_modified_at = timezone.now()
def get_web_image_url_template(self, obj):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_WEB_IMAGE_URL_TEMPLATE[obj.integration]
@ -401,6 +404,7 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode
self.instance.web_image_url_template = value.strip()
elif default_template is not None and default_template.strip() == value.strip():
self.instance.web_image_url_template = None
self.instance.web_templates_modified_at = timezone.now()
def get_telegram_title_template(self, obj):
default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_TELEGRAM_TITLE_TEMPLATE[obj.integration]

View file

@ -58,6 +58,7 @@ def test_direct_paging_new_alert_group(
)
assert response.status_code == status.HTTP_200_OK
assert "alert_group_id" in response.json()
@pytest.mark.django_db
@ -104,6 +105,7 @@ def test_direct_paging_existing_alert_group(
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["alert_group_id"] == alert_group.public_primary_key
@pytest.mark.django_db

View file

@ -85,7 +85,11 @@ class AlertGroupFilter(DateRangeFilterMixin, ModelFieldFilterMixin, filters.Filt
invitees_are = filters.ModelMultipleChoiceFilter(
queryset=get_user_queryset, to_field_name="public_primary_key", method="filter_invitees_are"
)
involved_users_are = filters.ModelMultipleChoiceFilter(
queryset=get_user_queryset, to_field_name="public_primary_key", method="filter_by_involved_users"
)
with_resolution_note = filters.BooleanFilter(method="filter_with_resolution_note")
mine = filters.BooleanFilter(method="filter_mine")
class Meta:
model = AlertGroup
@ -132,10 +136,27 @@ class AlertGroupFilter(DateRangeFilterMixin, ModelFieldFilterMixin, filters.Filt
if not users:
return queryset
queryset = queryset.filter(acknowledged=False, resolved=False, log_records__author__in=users).distinct()
queryset = queryset.filter(log_records__author__in=users).distinct()
return queryset
def filter_by_involved_users(self, queryset, name, value):
users = value
if not users:
return queryset
queryset = queryset.filter(
Q(personal_log_records__author__in=users) | Q(log_records__author__in=users)
).distinct()
return queryset
def filter_mine(self, queryset, name, value):
if value:
return self.filter_by_involved_users(queryset, "users", [self.request.user.pk])
return queryset
def filter_with_resolution_note(self, queryset, name, value):
if value is True:
queryset = queryset.filter(Q(resolution_notes__isnull=False, resolution_notes__deleted_at=None)).distinct()
@ -522,6 +543,12 @@ class AlertGroupView(
"type": "options",
"href": api_root + "users/?filters=true&roles=0&roles=1&roles=2",
},
{
"name": "involved_users_are",
"type": "options",
"href": api_root + "users/?filters=true&roles=0&roles=1&roles=2",
"default": {"display_name": self.request.user.username, "value": self.request.user.public_primary_key},
},
{
"name": "status",
"type": "options",
@ -548,6 +575,11 @@ class AlertGroupView(
"type": "boolean",
"default": "true",
},
{
"name": "mine",
"type": "boolean",
"default": "true",
},
]
if filter_name is not None:

View file

@ -30,7 +30,7 @@ class DirectPagingAPIView(APIView):
(schedule["instance"], schedule["important"]) for schedule in serializer.validated_data["schedules"]
]
direct_paging(
alert_group = direct_paging(
organization=organization,
team=team,
from_user=from_user,
@ -42,4 +42,4 @@ class DirectPagingAPIView(APIView):
alert_group=serializer.validated_data["alert_group"],
)
return Response(status=status.HTTP_200_OK)
return Response(data={"alert_group_id": alert_group.public_primary_key}, status=status.HTTP_200_OK)

View file

@ -559,20 +559,37 @@ def _get_users_select(organization, team, input_id_prefix):
}
for user in users
]
if not user_options:
user_select = {"type": "context", "elements": [{"type": "mrkdwn", "text": "No users available"}]}
return {"type": "context", "elements": [{"type": "mrkdwn", "text": "No users available"}]}
user_select = {
"type": "section",
"text": {"type": "mrkdwn", "text": "Add responders"},
"block_id": input_id_prefix + DIRECT_PAGING_USER_SELECT_ID,
"accessory": {
"type": "static_select",
"placeholder": {"type": "plain_text", "text": "Select a user", "emoji": True},
"action_id": OnPagingUserChange.routing_uid(),
},
}
if len(user_options) > scenario_step.MAX_STATIC_SELECT_OPTIONS:
# paginate user options in groups
max_length = scenario_step.MAX_STATIC_SELECT_OPTIONS
chunks = [user_options[x : x + max_length] for x in range(0, len(user_options), max_length)]
option_groups = [
{
"label": {"type": "plain_text", "text": f"({(i * max_length)+1}-{(i * max_length)+max_length})"},
"options": group,
}
for i, group in enumerate(chunks)
]
user_select["accessory"]["option_groups"] = option_groups
else:
user_select = {
"type": "section",
"text": {"type": "mrkdwn", "text": "Add responders"},
"block_id": input_id_prefix + DIRECT_PAGING_USER_SELECT_ID,
"accessory": {
"type": "static_select",
"placeholder": {"type": "plain_text", "text": "Select a user", "emoji": True},
"options": user_options,
"action_id": OnPagingUserChange.routing_uid(),
},
}
user_select["accessory"]["options"] = user_options
return user_select

View file

@ -10,6 +10,7 @@
"build": "grafana-toolkit plugin:build",
"build:dev": "grafana-toolkit plugin:build --skipTest --skipLint",
"test": "jest --verbose",
"test:silent": "jest --silent",
"dev": "grafana-toolkit plugin:dev",
"watch": "grafana-toolkit plugin:dev --watch",
"sign": "grafana-toolkit plugin:sign",

View file

@ -1,9 +1,8 @@
import React, { FC, useMemo } from 'react';
import React, { FC, useCallback, useMemo } from 'react';
import cn from 'classnames/bind';
import { Link } from 'react-router-dom';
import Text from 'components/Text/Text';
import { getPathFromQueryParams } from 'utils/url';
import styles from './PluginLink.module.css';
@ -23,12 +22,23 @@ const PluginLink: FC<PluginLinkProps> = (props) => {
const newPath = useMemo(() => getPathFromQueryParams(query), [query]);
return disabled ? (
<Text className={cx('root', className, { 'no-wrap': !wrap })} type="disabled">
{children}
</Text>
) : (
<Link className={cx('root', className, { 'no-wrap': !wrap })} to={newPath}>
const handleClick = useCallback(
(event) => {
event.stopPropagation();
if (disabled) {
event.preventDefault();
}
},
[disabled]
);
return (
<Link
onClick={handleClick}
className={cx('root', className, { 'no-wrap': !wrap, root_disabled: disabled })}
to={newPath}
>
{children}
</Link>
);

View file

@ -30,8 +30,8 @@ const ScheduleUserDetails: FC<ScheduleUserDetailsProps> = (props) => {
const store = useStore();
const { teamStore } = store;
let slackWorkspaceNameOrigin = teamStore.currentTeam.slack_team_identity.cached_name;
const slackWorkspaceName = slackWorkspaceNameOrigin.replace(/[^0-9a-z]/gi, '');
const slackWorkspaceName = teamStore.currentTeam.slack_team_identity?.cached_name?.replace(/[^0-9a-z]/gi, '') || '';
return (
<div className={cx('root')}>
<VerticalGroup spacing="md">

View file

@ -8,6 +8,7 @@ import { SortableContainer, SortableElement, SortableHandle } from 'react-sortab
import Text from 'components/Text/Text';
import RemoteSelect from 'containers/RemoteSelect/RemoteSelect';
import { User } from 'models/user/user.types';
import { UserActions } from 'utils/authorization';
import { fromPlainArray, toPlainArray } from './UserGroups.helpers';
import { Item } from './UserGroups.types';
@ -114,6 +115,7 @@ const UserGroups = (props: UserGroupsProps) => {
onChange={handleUserAdd}
showError={showError}
maxMenuHeight={150}
requiredUserAction={UserActions.UserSettingsWrite}
/>
<SortableList
renderItem={renderItem}

View file

@ -86,12 +86,16 @@ export const getNonWorkingMoments = (startMoment, endMoment, workingHours) => {
export const isInWorkingHours = (currentMoment: dayjs.Dayjs, workingHours, timezone) => {
const timeFormat = 'HH:mm:ss';
const currentDayOfTheWeek = currentMoment.format('dddd').toLowerCase();
const workingHourStart = workingHours[currentDayOfTheWeek][0].start;
const workingHourEnd = workingHours[currentDayOfTheWeek][0].end;
const workingHourStart = workingHours[currentDayOfTheWeek][0]?.start;
const workingHourEnd = workingHours[currentDayOfTheWeek][0]?.end;
const startTime = dayjs(workingHourStart, timeFormat).tz(timezone).format(timeFormat);
const endTime = dayjs(workingHourEnd, timeFormat).tz(timezone).format(timeFormat);
const currentTime = dayjs(currentMoment, timeFormat).format(timeFormat);
if (workingHourStart && workingHourEnd) {
const startTime = dayjs(workingHourStart, timeFormat).tz(timezone).format(timeFormat);
const endTime = dayjs(workingHourEnd, timeFormat).tz(timezone).format(timeFormat);
const currentTime = dayjs(currentMoment, timeFormat).format(timeFormat);
return currentTime < endTime && currentTime >= startTime;
return currentTime < endTime && currentTime >= startTime;
}
return false;
};

View file

@ -12,7 +12,7 @@ import { WithPermissionControl } from 'containers/WithPermissionControl/WithPerm
import { ApiToken } from 'models/api_token/api_token.types';
import { WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { isUserActionAllowed, UserActions } from 'utils/authorization';
import { generateMissingPermissionMessage, isUserActionAllowed, UserActions } from 'utils/authorization';
import ApiTokenForm from './ApiTokenForm';
@ -21,6 +21,7 @@ import styles from './ApiTokenSettings.module.css';
const cx = cn.bind(styles);
const MAX_TOKENS_PER_USER = 5;
const REQUIRED_PERMISSION_TO_VIEW = UserActions.APIKeysWrite;
interface ApiTokensProps extends WithStoreProps {}
@ -67,6 +68,15 @@ class ApiTokens extends React.Component<ApiTokensProps, any> {
},
];
const authorizedToViewAPIKeys = isUserActionAllowed(REQUIRED_PERMISSION_TO_VIEW);
let emptyText = 'Loading...';
if (!authorizedToViewAPIKeys) {
emptyText = `${generateMissingPermissionMessage(REQUIRED_PERMISSION_TO_VIEW)} to be able to view API tokens.`;
} else if (apiTokens) {
emptyText = 'No tokens found';
}
return (
<>
<GTable
@ -92,13 +102,7 @@ class ApiTokens extends React.Component<ApiTokensProps, any> {
className="api-keys"
showHeader={!isMobile}
data={apiTokens}
emptyText={
isUserActionAllowed(UserActions.APIKeysWrite)
? apiTokens
? 'No tokens found'
: 'Loading...'
: 'API tokens are available only for users with Admin permissions'
}
emptyText={emptyText}
columns={columns}
/>
{showCreateTokenModal && (

View file

@ -71,6 +71,7 @@ class IncidentsFilters extends Component<IncidentsFiltersProps, IncidentsFilters
} else {
newQuery = {
status: [IncidentStatus.New, IncidentStatus.Acknowledged],
mine: false,
};
}

View file

@ -12,6 +12,7 @@ import { User } from 'models/user/user.types';
import { AppFeature } from 'state/features';
import { useStore } from 'state/useStore';
import { isUserActionAllowed, UserActions } from 'utils/authorization';
import { GRAFANA_LICENSE_OSS } from 'utils/consts';
import styles from './MobileAppConnection.module.scss';
import DisconnectButton from './parts/DisconnectButton/DisconnectButton';
@ -153,6 +154,8 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
</VerticalGroup>
);
} else if (QRCodeValue) {
const QRCodeDataParsed = getParsedQRCodeValue();
content = (
<VerticalGroup spacing="lg">
<Text type="primary" strong>
@ -163,6 +166,15 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
<QRCode className={cx({ 'qr-code': true, blurry: isQRBlurry })} value={QRCodeValue} />
{isQRBlurry && <QRLoading />}
</div>
{store.backendLicense === GRAFANA_LICENSE_OSS && QRCodeDataParsed && (
<Text type="secondary">
Server URL embedded in this QR:
<br />
<a href={QRCodeDataParsed.oncall_api_url}>
<Text type="link">{QRCodeDataParsed.oncall_api_url}</Text>
</a>
</Text>
)}
</VerticalGroup>
);
}
@ -178,6 +190,14 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
</div>
);
function getParsedQRCodeValue() {
try {
return JSON.parse(QRCodeValue);
} catch (ex) {
return undefined;
}
}
function clearTimeouts(): void {
clearTimeout(userTimeoutId);
clearTimeout(refreshTimeoutId);

View file

@ -58,20 +58,27 @@ exports[`MobileAppConnection if we disconnect the app, it disconnects and fetche
<div
class="css-bxa289-layoutChildrenWrapper"
>
<div
class="root icon-block root_bordered root--fullWidth root--hover"
<a
href="https://play.google.com/store/apps/details?id=com.grafana.oncall.prod"
rel="noreferrer"
style="width: 100%;"
target="_blank"
>
<img
alt="Play Store"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
<div
class="root icon-block root_bordered root--fullWidth root--hover"
>
Android
</span>
</div>
<img
alt="Play Store"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
Android
</span>
</div>
</a>
</div>
</div>
</div>
@ -2389,20 +2396,27 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect
<div
class="css-bxa289-layoutChildrenWrapper"
>
<div
class="root icon-block root_bordered root--fullWidth root--hover"
<a
href="https://play.google.com/store/apps/details?id=com.grafana.oncall.prod"
rel="noreferrer"
style="width: 100%;"
target="_blank"
>
<img
alt="Play Store"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
<div
class="root icon-block root_bordered root--fullWidth root--hover"
>
Android
</span>
</div>
<img
alt="Play Store"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
Android
</span>
</div>
</a>
</div>
</div>
</div>
@ -2488,20 +2502,27 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco
<div
class="css-bxa289-layoutChildrenWrapper"
>
<div
class="root icon-block root_bordered root--fullWidth root--hover"
<a
href="https://play.google.com/store/apps/details?id=com.grafana.oncall.prod"
rel="noreferrer"
style="width: 100%;"
target="_blank"
>
<img
alt="Play Store"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
<div
class="root icon-block root_bordered root--fullWidth root--hover"
>
Android
</span>
</div>
<img
alt="Play Store"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
Android
</span>
</div>
</a>
</div>
</div>
</div>
@ -2587,20 +2608,27 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch
<div
class="css-bxa289-layoutChildrenWrapper"
>
<div
class="root icon-block root_bordered root--fullWidth root--hover"
<a
href="https://play.google.com/store/apps/details?id=com.grafana.oncall.prod"
rel="noreferrer"
style="width: 100%;"
target="_blank"
>
<img
alt="Play Store"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
<div
class="root icon-block root_bordered root--fullWidth root--hover"
>
Android
</span>
</div>
<img
alt="Play Store"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
Android
</span>
</div>
</a>
</div>
</div>
</div>
@ -2686,20 +2714,27 @@ exports[`MobileAppConnection it shows a message when the mobile app is already c
<div
class="css-bxa289-layoutChildrenWrapper"
>
<div
class="root icon-block root_bordered root--fullWidth root--hover"
<a
href="https://play.google.com/store/apps/details?id=com.grafana.oncall.prod"
rel="noreferrer"
style="width: 100%;"
target="_blank"
>
<img
alt="Play Store"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
<div
class="root icon-block root_bordered root--fullWidth root--hover"
>
Android
</span>
</div>
<img
alt="Play Store"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
Android
</span>
</div>
</a>
</div>
</div>
</div>
@ -2885,20 +2920,27 @@ exports[`MobileAppConnection it shows an error message if there was an error dis
<div
class="css-bxa289-layoutChildrenWrapper"
>
<div
class="root icon-block root_bordered root--fullWidth root--hover"
<a
href="https://play.google.com/store/apps/details?id=com.grafana.oncall.prod"
rel="noreferrer"
style="width: 100%;"
target="_blank"
>
<img
alt="Play Store"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
<div
class="root icon-block root_bordered root--fullWidth root--hover"
>
Android
</span>
</div>
<img
alt="Play Store"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
Android
</span>
</div>
</a>
</div>
</div>
</div>
@ -2975,20 +3017,27 @@ exports[`MobileAppConnection it shows an error message if there was an error fet
<div
class="css-bxa289-layoutChildrenWrapper"
>
<div
class="root icon-block root_bordered root--fullWidth root--hover"
<a
href="https://play.google.com/store/apps/details?id=com.grafana.oncall.prod"
rel="noreferrer"
style="width: 100%;"
target="_blank"
>
<img
alt="Play Store"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
<div
class="root icon-block root_bordered root--fullWidth root--hover"
>
Android
</span>
</div>
<img
alt="Play Store"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
Android
</span>
</div>
</a>
</div>
</div>
</div>

View file

@ -52,20 +52,27 @@ exports[`DownloadIcons it renders properly 1`] = `
<div
class="css-bxa289-layoutChildrenWrapper"
>
<div
class="root icon-block root_bordered root--fullWidth root--hover"
<a
href="https://play.google.com/store/apps/details?id=com.grafana.oncall.prod"
rel="noreferrer"
style="width: 100%;"
target="_blank"
>
<img
alt="Play Store"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
<div
class="root icon-block root_bordered root--fullWidth root--hover"
>
Android
</span>
</div>
<img
alt="Play Store"
class="icon"
src="[object Object]"
/>
<span
class="root text icon-text text--primary text--medium"
>
Android
</span>
</div>
</a>
</div>
</div>
</div>

View file

@ -25,12 +25,19 @@ const DownloadIcons: FC = () => (
iOS
</Text>
</Block>
<Block hover fullWidth bordered className={cx('icon-block')}>
<img src={PlayStoreLogoSVG} alt="Play Store" className={cx('icon')} />
<Text type="primary" className={cx('icon-text')}>
Android
</Text>
</Block>
<a
style={{ width: '100%' }}
href="https://play.google.com/store/apps/details?id=com.grafana.oncall.prod"
target="_blank"
rel="noreferrer"
>
<Block hover fullWidth bordered className={cx('icon-block')}>
<img src={PlayStoreLogoSVG} alt="Play Store" className={cx('icon')} />
<Text type="primary" className={cx('icon-text')}>
Android
</Text>
</Block>
</a>
</VerticalGroup>
</VerticalGroup>
);

View file

@ -1,10 +1,11 @@
import React, { useCallback, useEffect, useMemo, useReducer } from 'react';
import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { AsyncMultiSelect, AsyncSelect } from '@grafana/ui';
import { inject, observer } from 'mobx-react';
import { makeRequest } from 'network';
import { makeRequest, isNetworkError } from 'network';
import { UserAction, generateMissingPermissionMessage } from 'utils/authorization';
interface RemoteSelectProps {
autoFocus?: boolean;
@ -24,6 +25,7 @@ interface RemoteSelectProps {
getOptionLabel?: (item: SelectableValue) => React.ReactNode;
showError?: boolean;
maxMenuHeight?: number;
requiredUserAction?: UserAction;
}
const RemoteSelect = inject('store')(
@ -45,9 +47,12 @@ const RemoteSelect = inject('store')(
openMenuOnFocus = true,
showError,
maxMenuHeight,
requiredUserAction,
} = props;
const getOptions = (data: any[]) => {
const [noOptionsMessage, setNoOptionsMessage] = useState<string>('No options found');
const getOptions = (data: any[]): SelectableValue[] => {
return data.map((option: any) => ({
value: option[valueField],
label: option[fieldToShow],
@ -62,17 +67,23 @@ const RemoteSelect = inject('store')(
const [options, setOptions] = useReducer(mergeOptions, []);
useEffect(() => {
makeRequest(href, {}).then((data) => {
setOptions(getOptions(data.results || data));
});
const loadOptionsCallback = useCallback(async (query?: string): Promise<SelectableValue[]> => {
try {
const data = await makeRequest(href, { params: { search: query } });
const options = getOptions(data.results || data);
setOptions(options);
return options;
} catch (e) {
if (isNetworkError(e) && e.response.status === 403 && requiredUserAction) {
setNoOptionsMessage(generateMissingPermissionMessage(requiredUserAction));
}
return [];
}
}, []);
const loadOptionsCallback = useCallback((query: string) => {
return makeRequest(href, { params: { search: query } }).then((data) => {
setOptions(getOptions(data.results || data));
return getOptions(data.results || data);
});
useEffect(() => {
loadOptionsCallback();
}, []);
const onChangeCallback = useCallback(
@ -119,6 +130,7 @@ const RemoteSelect = inject('store')(
defaultOptions={options}
loadOptions={loadOptionsCallback}
getOptionLabel={getOptionLabel}
noOptionsMessage={noOptionsMessage}
invalid={showError}
/>
);

View file

@ -133,7 +133,6 @@ export const TabsContent = observer(({ id, activeTab, onTabChange, isDesktopOrLa
) : (
<PhoneVerification userPk={id} />
))}
{/* TODO: we should probably hide this tab when a user (ie. Admin) is viewing the user settings for another user. Would it make sense for an Admin to be able to link their mobile app to another user's profile */}
{activeTab === UserSettingsTab.MobileAppConnection && <MobileAppConnection userPk={id} />}
{activeTab === UserSettingsTab.SlackInfo && <SlackTab />}
{activeTab === UserSettingsTab.TelegramInfo && <TelegramInfo />}

View file

@ -34,6 +34,8 @@ interface RequestConfig {
validateStatus?: (status: number) => boolean;
}
export const isNetworkError = axios.isAxiosError;
export const makeRequest = async <RT = any>(path: string, config: RequestConfig) => {
const { method = 'GET', params, data, validateStatus } = config;

View file

@ -127,7 +127,6 @@ export const pages: { [id: string]: PageDefinition } = [
icon: 'table',
id: 'live-settings',
text: 'Env Variables',
role: 'Admin',
hideFromTabsFn: (store: RootBaseStore) => {
const hasLiveSettings = store.hasFeature(AppFeature.LiveSettings);
return isTopNavbar() || !hasLiveSettings;
@ -139,7 +138,6 @@ export const pages: { [id: string]: PageDefinition } = [
icon: 'cloud',
id: 'cloud',
text: 'Cloud',
role: 'Admin',
hideFromTabsFn: (store: RootBaseStore) => {
const hasCloudFeature = store.hasFeature(AppFeature.CloudConnection);
return isTopNavbar() || !hasCloudFeature;

View file

@ -16,6 +16,7 @@ import { WithStoreProps } from 'state/types';
import { useStore } from 'state/useStore';
import { withMobXProviderContext } from 'state/withStore';
import { openErrorNotification } from 'utils';
import { determineRequiredAuthString, UserActions } from 'utils/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
import styles from './CloudPage.module.css';
@ -261,10 +262,9 @@ const CloudPage = observer((props: CloudPageProps) => {
<div style={{ width: '100%' }}>
<Text type="secondary">
{/* TODO: should probably update this message? */}
{
'Ask your users to sign up in Grafana Cloud, verify phone number and feel free to set up SMS & phone call notifications in personal settings! Only users with Admin or Editor role will be synced.'
}
{`Ask your users to sign up in Grafana Cloud, verify phone number and feel free to set up SMS & phone call notifications in personal settings! Users must have ${determineRequiredAuthString(
UserActions.NotificationsRead
)} in order to be synced.`}
</Text>
<GTable
@ -280,15 +280,9 @@ const CloudPage = observer((props: CloudPageProps) => {
{matched_users_count === 1 ? '' : 's'}
{` matched between OSS and Cloud OnCall`}
</Text>
{syncingUsers ? (
<Button variant="primary" onClick={syncUsers} icon="sync" disabled>
Syncing...
</Button>
) : (
<Button variant="primary" onClick={syncUsers} icon="sync">
Sync users (Editors and Admins)
</Button>
)}
<Button variant="primary" onClick={syncUsers} icon="sync" disabled={syncingUsers}>
{syncingUsers ? 'Syncing...' : 'Sync users'}
</Button>
</HorizontalGroup>
</div>
)}

View file

@ -23,7 +23,7 @@ import { User as UserType } from 'models/user/user.types';
import { PageProps, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import LocationHelper from 'utils/LocationHelper';
import { isUserActionAllowed, UserActions } from 'utils/authorization';
import { generateMissingPermissionMessage, isUserActionAllowed, UserActions } from 'utils/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
import { getUserRowClassNameFn } from './Users.helpers';
@ -35,6 +35,7 @@ const cx = cn.bind(styles);
interface UsersProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {}
const ITEMS_PER_PAGE = 100;
const REQUIRED_PERMISSION_TO_VIEW_USERS = UserActions.UserSettingsWrite;
interface UsersState extends PageBaseState {
page: number;
@ -43,6 +44,7 @@ interface UsersState extends PageBaseState {
usersFilters?: {
searchTerm: string;
};
initialUsersLoaded: boolean;
}
@observer
@ -56,10 +58,9 @@ class Users extends React.Component<UsersProps, UsersState> {
},
errorData: initErrorDataState(),
initialUsersLoaded: false,
};
initialUsersLoaded = false;
async componentDidMount() {
const {
query: { p },
@ -74,18 +75,19 @@ class Users extends React.Component<UsersProps, UsersState> {
const { usersFilters, page } = this.state;
const { userStore } = store;
if (!isUserActionAllowed(UserActions.UserSettingsWrite)) {
if (!isUserActionAllowed(REQUIRED_PERMISSION_TO_VIEW_USERS)) {
return;
}
LocationHelper.update({ p: page }, 'partial');
return await userStore.updateItems(usersFilters, page);
await userStore.updateItems(usersFilters, page);
this.setState({ initialUsersLoaded: true });
};
componentDidUpdate(prevProps: UsersProps) {
if (!this.initialUsersLoaded && isUserActionAllowed(UserActions.UserSettingsWrite)) {
if (!this.state.initialUsersLoaded) {
this.updateUsers();
this.initialUsersLoaded = true;
}
if (prevProps.match.params.id !== this.props.match.params.id) {
@ -117,7 +119,7 @@ class Users extends React.Component<UsersProps, UsersState> {
};
render() {
const { usersFilters, userPkToEdit, page, errorData } = this.state;
const { usersFilters, userPkToEdit, page, errorData, initialUsersLoaded } = this.state;
const {
store,
match: {
@ -165,6 +167,8 @@ class Users extends React.Component<UsersProps, UsersState> {
const { count, results } = userStore.getSearchResult();
const authorizedToViewUsers = isUserActionAllowed(REQUIRED_PERMISSION_TO_VIEW_USERS);
return (
<PageErrorHandlingWrapper
errorData={errorData}
@ -182,10 +186,12 @@ class Users extends React.Component<UsersProps, UsersState> {
<LegacyNavHeading>
<Text.Title level={3}>Users</Text.Title>
</LegacyNavHeading>
<Text type="secondary">
To manage permissions or add users, please visit{' '}
<a href="/org/users">Grafana user management</a>
</Text>
{authorizedToViewUsers && (
<Text type="secondary">
To manage permissions or add users, please visit{' '}
<a href="/org/users">Grafana user management</a>
</Text>
)}
</div>
</div>
<PluginLink query={{ page: 'users', id: 'me' }}>
@ -194,7 +200,7 @@ class Users extends React.Component<UsersProps, UsersState> {
</Button>
</PluginLink>
</div>
{isUserActionAllowed(UserActions.UserSettingsRead) ? (
{authorizedToViewUsers ? (
<>
<div className={cx('user-filters-container')}>
<UsersFilters
@ -213,7 +219,7 @@ class Users extends React.Component<UsersProps, UsersState> {
</div>
<GTable
emptyText={results ? 'No users found' : 'Loading...'}
emptyText={initialUsersLoaded ? 'No users found' : 'Loading...'}
rowKey="pk"
data={results}
columns={columns}
@ -230,8 +236,9 @@ class Users extends React.Component<UsersProps, UsersState> {
/* @ts-ignore */
title={
<>
You don't have enough permissions to view other users because you are not Admin.{' '}
<PluginLink query={{ page: 'users', id: 'me' }}>Click here</PluginLink> to open your profile
{generateMissingPermissionMessage(REQUIRED_PERMISSION_TO_VIEW_USERS)} to be able to view OnCall
users. <PluginLink query={{ page: 'users', id: 'me' }}>Click here</PluginLink> to open your
profile
</>
}
severity="info"

View file

@ -535,7 +535,7 @@
{
"role": {
"name": "User Settings Reader",
"description": "Read-only access to OnCall User Settings",
"description": "Read-only access to own OnCall User Settings",
"permissions": [
{ "action": "plugins.app:access", "scope": "plugins:id:grafana-oncall-app" },
{ "action": "grafana-oncall-app.user-settings:read" }
@ -546,7 +546,7 @@
{
"role": {
"name": "User Settings Editor",
"description": "Read/write access to own OnCall User Settings",
"description": "Read/write access to own OnCall User Settings + ability to view basic information about other OnCall users",
"permissions": [
{ "action": "plugins.app:access", "scope": "plugins:id:grafana-oncall-app" },
{ "action": "grafana-oncall-app.user-settings:read" },

View file

@ -34,19 +34,19 @@ exports[`PluginState.generateUnknownErrorMsg it returns the proper error message
Refresh your page and try again, or try removing your plugin configuration and reconfiguring."
`;
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a 400 AxiosError properly - has custom error message: false 1`] = `
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a 400 network error properly - has custom error message: false 1`] = `
"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)?
Refresh your page and try again, or try removing your plugin configuration and reconfiguring."
`;
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a 400 AxiosError properly - has custom error message: true 1`] = `"ohhhh nooo an error"`;
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a 400 network error properly - has custom error message: true 1`] = `"ohhhh nooo an error"`;
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 AxiosError properly - status code: 409 1`] = `
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 network error properly - status code: 409 1`] = `
"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)?
Refresh your page and try again, or try removing your plugin configuration and reconfiguring."
`;
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 AxiosError properly - status code: 502 1`] = `
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 network error properly - status code: 502 1`] = `
"Could not communicate with your OnCall API at http://hello.com (NOTE: your OnCall API URL is currently being taken from process.env of your UI).
Validate that the URL is correct, your OnCall API is running, and that it is accessible from your Grafana instance."
`;

View file

@ -1,8 +1,7 @@
import { getBackendSrv } from '@grafana/runtime';
import axios from 'axios';
import { OnCallAppPluginMeta, OnCallPluginMetaJSONData, OnCallPluginMetaSecureJSONData } from 'types';
import { makeRequest } from 'network';
import { makeRequest, isNetworkError } from 'network';
import FaroHelper from 'utils/faro';
export type UpdateGrafanaPluginSettingsProps = {
@ -76,7 +75,7 @@ class PluginState {
);
const consoleMsg = `occured while trying to ${installationVerb} the plugin w/ the OnCall backend`;
if (axios.isAxiosError(e)) {
if (isNetworkError(e)) {
const { status: statusCode } = e.response;
console.warn(`An HTTP related error ${consoleMsg}`, e.response);
@ -100,7 +99,7 @@ class PluginState {
errorMsg = unknownErrorMsg;
}
} else {
// a non-axios related error occured.. this scenario shouldn't occur...
// a non-network related error occured.. this scenario shouldn't occur...
console.warn(`An unknown error ${consoleMsg}`, e);
errorMsg = unknownErrorMsg;
}
@ -115,12 +114,12 @@ class PluginState {
): string => {
let errorMsg: string;
if (axios.isAxiosError(e)) {
if (isNetworkError(e)) {
// The user likely put in a bogus URL for the OnCall API URL
console.warn('An HTTP related error occured while trying to provision the plugin w/ Grafana', e.response);
errorMsg = this.generateInvalidOnCallApiURLErrorMsg(onCallApiUrl, onCallApiUrlIsConfiguredThroughEnvVar);
} else {
// a non-axios related error occured.. this scenario shouldn't occur...
// a non-network related error occured.. this scenario shouldn't occur...
console.warn('An unknown error occured while trying to provision the plugin w/ Grafana', e);
errorMsg = this.generateUnknownErrorMsg(onCallApiUrl, installationVerb, onCallApiUrlIsConfiguredThroughEnvVar);
}

View file

@ -1,8 +1,9 @@
import { makeRequest as makeRequestOriginal } from 'network';
import { makeRequest as makeRequestOriginal, isNetworkError as isNetworkErrorOriginal } from 'network';
import PluginState, { InstallationVerb, PluginSyncStatusResponse, UpdateGrafanaPluginSettingsProps } from './';
const makeRequest = makeRequestOriginal as jest.Mock<ReturnType<typeof makeRequestOriginal>>;
const isNetworkError = isNetworkErrorOriginal as unknown as jest.Mock<ReturnType<typeof isNetworkErrorOriginal>>;
jest.mock('network');
@ -13,7 +14,7 @@ afterEach(() => {
const ONCALL_BASE_URL = '/plugin';
const GRAFANA_PLUGIN_SETTINGS_URL = '/api/plugins/grafana-oncall-app/settings';
const generateMockAxiosError = (status: number, data = {}) => ({ isAxiosError: true, response: { status, ...data } });
const generateMockNetworkError = (status: number, data = {}) => ({ response: { status, ...data } });
describe('PluginState.generateOnCallApiUrlConfiguredThroughEnvVarMsg', () => {
test.each([true, false])(
@ -54,10 +55,12 @@ describe('PluginState.getHumanReadableErrorFromOnCallError', () => {
console.warn = () => {};
});
test.each([502, 409])('it handles a non-400 AxiosError properly - status code: %s', (status) => {
test.each([502, 409])('it handles a non-400 network error properly - status code: %s', (status) => {
isNetworkError.mockReturnValueOnce(true);
expect(
PluginState.getHumanReadableErrorFromOnCallError(
generateMockAxiosError(status),
generateMockNetworkError(status),
'http://hello.com',
'install',
true
@ -66,19 +69,23 @@ describe('PluginState.getHumanReadableErrorFromOnCallError', () => {
});
test.each([true, false])(
'it handles a 400 AxiosError properly - has custom error message: %s',
'it handles a 400 network error properly - has custom error message: %s',
(hasCustomErrorMessage) => {
const axiosError = generateMockAxiosError(400) as any;
isNetworkError.mockReturnValueOnce(true);
const networkError = generateMockNetworkError(400) as any;
if (hasCustomErrorMessage) {
axiosError.response.data = { error: 'ohhhh nooo an error' };
networkError.response.data = { error: 'ohhhh nooo an error' };
}
expect(
PluginState.getHumanReadableErrorFromOnCallError(axiosError, 'http://hello.com', 'install', true)
PluginState.getHumanReadableErrorFromOnCallError(networkError, 'http://hello.com', 'install', true)
).toMatchSnapshot();
}
);
test('it handles an unknown error properly', () => {
isNetworkError.mockReturnValueOnce(false);
expect(
PluginState.getHumanReadableErrorFromOnCallError(new Error('asdfasdf'), 'http://hello.com', 'install', true)
).toMatchSnapshot();
@ -86,23 +93,27 @@ describe('PluginState.getHumanReadableErrorFromOnCallError', () => {
});
describe('PluginState.getHumanReadableErrorFromGrafanaProvisioningError', () => {
test.each([true, false])('it handles an error properly', (isAxiosError) => {
beforeEach(() => {
console.warn = () => {};
});
test.each([true, false])('it handles an error properly - network error: %s', (networkError) => {
const onCallApiUrl = 'http://hello.com';
const installationVerb = 'install';
const onCallApiUrlIsConfiguredThroughEnvVar = true;
const axiosError = generateMockAxiosError(400);
const nonAxiosError = new Error('oh noooo');
const error = isAxiosError ? axiosError : nonAxiosError;
const error = networkError ? generateMockNetworkError(400) : new Error('oh noooo');
const mockGenerateInvalidOnCallApiURLErrorMsgResult = 'asdadslkjfkjlsd';
const mockGenerateUnknownErrorMsgResult = 'asdadslkjfkjlsd';
isNetworkError.mockReturnValueOnce(networkError);
PluginState.generateInvalidOnCallApiURLErrorMsg = jest
.fn()
.mockReturnValueOnce(mockGenerateInvalidOnCallApiURLErrorMsgResult);
PluginState.generateUnknownErrorMsg = jest.fn().mockReturnValueOnce(mockGenerateUnknownErrorMsgResult);
const expectedErrorMsg = isAxiosError
const expectedErrorMsg = networkError
? mockGenerateInvalidOnCallApiURLErrorMsgResult
: mockGenerateUnknownErrorMsgResult;
@ -115,7 +126,7 @@ describe('PluginState.getHumanReadableErrorFromGrafanaProvisioningError', () =>
)
).toEqual(expectedErrorMsg);
if (isAxiosError) {
if (networkError) {
expect(PluginState.generateInvalidOnCallApiURLErrorMsg).toHaveBeenCalledTimes(1);
expect(PluginState.generateInvalidOnCallApiURLErrorMsg).toHaveBeenCalledWith(
onCallApiUrl,

View file

@ -63,6 +63,32 @@ describe('isUserActionAllowed', () => {
});
});
describe('determineRequiredAuthString', () => {
const testPerm = auth.UserActions.UserSettingsRead;
test.each([
[true, `${testPerm.permission} permission`],
[false, `${testPerm.fallbackMinimumRoleRequired} role`],
])('RBAC enabled: %s', (rbacEnabled, expected) => {
config.featureToggles.accessControlOnCall = rbacEnabled;
expect(auth.determineRequiredAuthString(testPerm)).toBe(expected);
});
});
describe('generateMissingPermissionMessage', () => {
const testPerm = auth.UserActions.UserSettingsRead;
test.each([
[true, `You are missing the ${testPerm.permission} permission`],
[false, `You are missing the ${testPerm.fallbackMinimumRoleRequired} role`],
])('RBAC enabled: %s', (rbacEnabled, expected) => {
config.featureToggles.accessControlOnCall = rbacEnabled;
expect(auth.generateMissingPermissionMessage(testPerm)).toBe(expected);
});
});
describe('generatePermissionString', () => {
test('it properly builds permission strings with prefixes', () => {
expect(auth.generatePermissionString(auth.Resource.API_KEYS, auth.Action.READ, true)).toEqual(

View file

@ -90,12 +90,24 @@ export const userHasMinimumRequiredRole = (minimumRoleRequired: OrgRole): boolea
*
* As a fallback (second argument), for cases where RBAC is not enabled for a grafana instance, rely on basic roles
*/
export const isUserActionAllowed = ({ permission, fallbackMinimumRoleRequired }: UserAction): boolean => {
if (config.featureToggles.accessControlOnCall) {
return !!contextSrv.user.permissions?.[permission];
}
return userHasMinimumRequiredRole(fallbackMinimumRoleRequired);
};
export const isUserActionAllowed = ({ permission, fallbackMinimumRoleRequired }: UserAction): boolean =>
config.featureToggles.accessControlOnCall
? !!contextSrv.user.permissions?.[permission]
: userHasMinimumRequiredRole(fallbackMinimumRoleRequired);
/**
* Given a `UserAction`, returns the permission or fallback-role, prefixed with "permission" or "role" respectively
* depending on whether or not RBAC is enabled/disabled
*/
export const determineRequiredAuthString = ({ permission, fallbackMinimumRoleRequired }: UserAction): string =>
config.featureToggles.accessControlOnCall ? `${permission} permission` : `${fallbackMinimumRoleRequired} role`;
/**
* Can be used to generate a user-friendly message about which permission is missing. Method is RBAC-aware
* and shows user the missing permission/basic-role depending on whether or not RBAC is enabled/disabled
*/
export const generateMissingPermissionMessage = (permission: UserAction): string =>
`You are missing the ${determineRequiredAuthString(permission)}`;
export const generatePermissionString = (resource: Resource, action: Action, includePrefix: boolean): string =>
`${includePrefix ? `${ONCALL_PERMISSION_PREFIX}.` : ''}${resource}:${action}`;

View file

@ -12411,9 +12411,9 @@ signal-exit@^3.0.2, signal-exit@^3.0.3:
integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
simple-git@^3.6.0:
version "3.15.0"
resolved "https://registry.yarnpkg.com/simple-git/-/simple-git-3.15.0.tgz#301a95a943c4f9b0a21d051eb6e6d0ffe4c9754f"
integrity sha512-FiWoMPlcYHQ+ApRihUsGjC/ZmIlWj62S6MBCwOunczvXcLQt+9ZdrysDrR6QVepkRQfEAaBXrN2QtJKrN6zbtg==
version "3.16.0"
resolved "https://registry.yarnpkg.com/simple-git/-/simple-git-3.16.0.tgz#421773e24680f5716999cc4a1d60127b4b6a9dec"
integrity sha512-zuWYsOLEhbJRWVxpjdiXl6eyAyGo/KzVW+KFhhw9MqEEJttcq+32jTWSGyxTdf9e/YCohxRE+9xpWFj9FdiJNw==
dependencies:
"@kwsites/file-exists" "^1.1.1"
"@kwsites/promise-deferred" "^1.1.1"