ShareModal: Share externally (#88259)

This commit is contained in:
Juan Cabanas
2024-06-12 17:02:06 -03:00
committed by GitHub
parent ed400f0bbf
commit 7664b89209
29 changed files with 1473 additions and 38 deletions

View File

@@ -65,6 +65,7 @@ export const Pages = {
menu: {
container: 'data-testid new share button menu',
shareInternally: 'data-testid new share button share internally',
shareExternally: 'data-testid new share button share externally',
},
},
playlistControls: {
@@ -280,6 +281,13 @@ export const Pages = {
CopyUrlInput: 'data-testid snapshot copy url input',
},
},
ShareDashboardDrawer: {
ShareExternally: {
container: 'data-testid share externally drawer container',
copyUrlButton: 'data-testid share externally copy url button',
shareTypeSelect: 'data-testid share externally share type select',
},
},
PublicDashboard: {
page: 'public-dashboard-page',
NotAvailable: {

View File

@@ -3,11 +3,11 @@ import React, { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { config, featureEnabled } from '@grafana/runtime';
import { config } from '@grafana/runtime';
import { useStyles2, TabsBar, Tab } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { contextSrv } from 'app/core/services/context_srv';
import { isPublicDashboardsEnabled } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
import { isEmailSharingEnabled } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
import { Page } from '../../core/components/Page/Page';
import { AccessControlAction } from '../../types';
@@ -47,10 +47,6 @@ export default function UserListPage() {
const hasAccessToAdminUsers = contextSrv.hasPermission(AccessControlAction.UsersRead);
const hasAccessToOrgUsers = contextSrv.hasPermission(AccessControlAction.OrgUsersRead);
const hasEmailSharingEnabled =
isPublicDashboardsEnabled() &&
Boolean(config.featureToggles.publicDashboardsEmailSharing) &&
featureEnabled('publicDashboardsEmailSharing');
const [view, setView] = useState(() => {
if (hasAccessToAdminUsers) {
@@ -87,10 +83,10 @@ export default function UserListPage() {
data-testid={selectors.tabs.anonUserDevices}
/>
)}
{hasEmailSharingEnabled && <PublicDashboardsTab view={view} setView={setView} />}
{isEmailSharingEnabled() && <PublicDashboardsTab view={view} setView={setView} />}
</TabsBar>
) : (
hasEmailSharingEnabled && (
isEmailSharingEnabled() && (
<TabsBar className={styles.tabsMargin}>
<Tab
label="Users"

View File

@@ -2,21 +2,22 @@ import React, { useCallback, useState } from 'react';
import { useAsyncFn } from 'react-use';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { VizPanel } from '@grafana/scenes';
import { Button, ButtonGroup, Dropdown } from '@grafana/ui';
import { createAndCopyDashboardShortLink } from 'app/core/utils/shortLinks';
import { DashboardScene } from '../../scene/DashboardScene';
import { DashboardInteractions } from '../../utils/interactions';
import ShareMenu from './ShareMenu';
import { buildShareUrl } from './utils';
const newShareButtonSelector = e2eSelectors.pages.Dashboard.DashNav.newShareButton;
export default function ShareButton({ dashboard }: { dashboard: DashboardScene }) {
export default function ShareButton({ dashboard, panel }: { dashboard: DashboardScene; panel?: VizPanel }) {
const [isOpen, setIsOpen] = useState(false);
const [_, buildUrl] = useAsyncFn(async () => {
return await createAndCopyDashboardShortLink(dashboard, { useAbsoluteTimeRange: true, theme: 'current' });
return await buildShareUrl(dashboard, panel);
}, [dashboard]);
const onMenuClick = useCallback((isOpen: boolean) => {

View File

@@ -5,6 +5,7 @@ import React from 'react';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { SceneGridLayout, SceneTimeRange, VizPanel } from '@grafana/scenes';
import { config } from '../../../../core/config';
import { DashboardGridItem } from '../../scene/DashboardGridItem';
import { DashboardScene } from '../../scene/DashboardScene';
@@ -18,6 +19,21 @@ jest.mock('app/core/utils/shortLinks', () => ({
const selector = e2eSelectors.pages.Dashboard.DashNav.newShareButton.menu;
describe('ShareMenu', () => {
it('should render menu items', async () => {
config.featureToggles.publicDashboards = true;
config.publicDashboardsEnabled = true;
setup();
expect(await screen.findByTestId(selector.shareInternally)).toBeInTheDocument();
expect(await screen.findByTestId(selector.shareExternally)).toBeInTheDocument();
});
it('should no share externally when public dashboard is disabled', async () => {
config.featureToggles.publicDashboards = false;
config.publicDashboardsEnabled = false;
setup();
expect(await screen.queryByTestId(selector.shareExternally)).not.toBeInTheDocument();
});
it('should call createAndCopyDashboardShortLink when share internally clicked', async () => {
setup();

View File

@@ -2,18 +2,32 @@ import React from 'react';
import { useAsyncFn } from 'react-use';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { VizPanel } from '@grafana/scenes';
import { Menu } from '@grafana/ui';
import { createAndCopyDashboardShortLink } from '../../../../core/utils/shortLinks';
import { isPublicDashboardsEnabled } from '../../../dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
import { DashboardScene } from '../../scene/DashboardScene';
import { ShareDrawer } from '../ShareDrawer/ShareDrawer';
import { ShareExternally } from './share-externally/ShareExternally';
import { buildShareUrl } from './utils';
const newShareButtonSelector = e2eSelectors.pages.Dashboard.DashNav.newShareButton.menu;
export default function ShareMenu({ dashboard }: { dashboard: DashboardScene }) {
export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardScene; panel?: VizPanel }) {
const [_, buildUrl] = useAsyncFn(async () => {
return await createAndCopyDashboardShortLink(dashboard, { useAbsoluteTimeRange: true, theme: 'current' });
return await buildShareUrl(dashboard, panel);
}, [dashboard]);
const onShareExternallyClick = () => {
const drawer = new ShareDrawer({
title: 'Share externally',
body: new ShareExternally({}),
});
dashboard.showModal(drawer);
};
return (
<Menu data-testid={newShareButtonSelector.container}>
<Menu.Item
@@ -23,6 +37,14 @@ export default function ShareMenu({ dashboard }: { dashboard: DashboardScene })
icon="building"
onClick={buildUrl}
/>
{isPublicDashboardsEnabled() && (
<Menu.Item
testId={newShareButtonSelector.shareExternally}
label="Share externally"
icon="share-alt"
onClick={onShareExternallyClick}
/>
)}
</Menu>
);
}

View File

@@ -0,0 +1,111 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { Button, Divider, Field, FieldSet, Icon, Stack, Tooltip } from '@grafana/ui';
import { Input } from '@grafana/ui/src/components/Input/Input';
import { contextSrv } from 'app/core/core';
import { t, Trans } from 'app/core/internationalization';
import { publicDashboardApi, useAddRecipientMutation } from 'app/features/dashboard/api/publicDashboardApi';
import { validEmailRegex } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
import { AccessControlAction } from 'app/types';
import { useShareDrawerContext } from '../../../../ShareDrawer/ShareDrawerContext';
import ShareConfiguration from '../../ShareConfiguration';
import { EmailListConfiguration } from './EmailListConfiguration';
const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard.EmailSharingConfiguration;
type EmailSharingForm = { email: string };
export const ConfigEmailSharing = () => {
const { dashboard } = useShareDrawerContext();
const { data: publicDashboard, isError } = publicDashboardApi.endpoints?.getPublicDashboard.useQueryState(
dashboard.state.uid!
);
const [addEmail, { isLoading: isAddEmailLoading }] = useAddRecipientMutation();
const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite);
const {
register,
handleSubmit,
formState: { errors, isValid },
reset,
} = useForm<EmailSharingForm>({
defaultValues: {
email: '',
},
mode: 'onSubmit',
});
const onSubmit = async (data: EmailSharingForm) => {
DashboardInteractions.publicDashboardEmailInviteClicked();
await addEmail({ recipient: data.email, uid: publicDashboard!.uid, dashboardUid: dashboard.state.uid! }).unwrap();
reset({ email: '' });
};
return (
<div>
<form onSubmit={handleSubmit(onSubmit)}>
<FieldSet disabled={!hasWritePermissions || isError}>
<Field
label={
<Stack gap={1} alignItems="center">
<Trans i18nKey="public-dashboard.email-sharing.recipient-invitation-button">Invite</Trans>
<Tooltip
placement="right"
content={t(
'public-dashboard.email-sharing.recipient-invitation-tooltip',
'This dashboard contains sensitive data. By using this feature you will be sharing with external people.'
)}
>
<Icon name="info-circle" size="sm" />
</Tooltip>
</Stack>
}
description={t(
'public-dashboard.email-sharing.recipient-invitation-description',
'Invite someone by email'
)}
error={errors.email?.message}
invalid={!!errors.email?.message}
>
<Stack direction="row">
<Input
placeholder={t(
'public-dashboard.email-sharing.recipient-email-placeholder',
'Type in the recipient email address and press Enter'
)}
autoCapitalize="none"
loading={isAddEmailLoading}
{...register('email', {
required: t('public-dashboard.email-sharing.recipient-required-email-text', 'Email is required'),
pattern: {
value: validEmailRegex,
message: t('public-dashboard.email-sharing.recipient-invalid-email-text', 'Invalid email'),
},
})}
data-testid={selectors.EmailSharingInput}
/>
<Button
type="submit"
variant="primary"
disabled={isAddEmailLoading || !isValid}
data-testid={selectors.EmailSharingInviteButton}
>
<Trans i18nKey="public-dashboard.email-sharing.recipient-invitation-button">Invite</Trans>
</Button>
</Stack>
</Field>
</FieldSet>
</form>
<EmailListConfiguration dashboard={dashboard} />
<Divider />
<ShareConfiguration />
</div>
);
};

View File

@@ -0,0 +1,148 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { Dropdown, Field, Icon, Menu, Spinner, Stack, Text, useStyles2 } from '@grafana/ui';
import { IconButton } from '@grafana/ui/';
import { t } from 'app/core/internationalization';
import {
useReshareAccessToRecipientMutation,
useDeleteRecipientMutation,
publicDashboardApi,
} from 'app/features/dashboard/api/publicDashboardApi';
import { PublicDashboard } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard.EmailSharingConfiguration;
const RecipientMenu = ({ onDelete, onReshare }: { onDelete: () => void; onReshare: () => void }) => {
return (
<Menu>
<Menu.Item label={t('public-dashboard.email-sharing.resend-invite-label', 'Resend invite')} onClick={onReshare} />
<Menu.Item
label={t('public-dashboard.email-sharing.revoke-access-label', 'Revoke access')}
destructive
onClick={onDelete}
/>
</Menu>
);
};
const EmailList = ({
recipients,
dashboardUid,
publicDashboard,
}: {
recipients: PublicDashboard['recipients'];
dashboardUid: string;
publicDashboard: PublicDashboard;
}) => {
const styles = useStyles2(getStyles);
const [deleteEmail, { isLoading: isDeleteLoading }] = useDeleteRecipientMutation();
const [reshareAccess, { isLoading: isReshareLoading }] = useReshareAccessToRecipientMutation();
const isLoading = isDeleteLoading || isReshareLoading;
const onDeleteEmail = (recipientUid: string, recipientEmail: string) => {
DashboardInteractions.revokePublicDashboardEmailClicked();
deleteEmail({ recipientUid, recipientEmail, dashboardUid: dashboardUid, uid: publicDashboard.uid });
};
const onReshare = (recipientUid: string) => {
DashboardInteractions.resendPublicDashboardEmailClicked();
reshareAccess({ recipientUid, uid: publicDashboard.uid });
};
return (
<table data-testid={selectors.EmailSharingList} className={styles.table}>
<tbody>
{recipients!.map((recipient, idx) => (
<tr key={recipient.uid} className={styles.listItem}>
<td className={styles.user}>
<Stack direction="row" gap={1} alignItems="center">
<div className={styles.icon}>
<Icon name="user" />
</div>
<Text>{recipient.recipient}</Text>
</Stack>
</td>
<td>{isLoading && <Spinner />}</td>
<td>
<Dropdown
overlay={
<RecipientMenu
onDelete={() => onDeleteEmail(recipient.uid, recipient.recipient)}
onReshare={() => onReshare(recipient.uid)}
/>
}
>
<IconButton name="ellipsis-v" aria-label="email-menu" variant="secondary" size="lg" />
</Dropdown>
</td>
</tr>
))}
</tbody>
</table>
);
};
export const EmailListConfiguration = ({ dashboard }: { dashboard: DashboardScene }) => {
const styles = useStyles2(getStyles);
const { data: publicDashboard } = publicDashboardApi.endpoints?.getPublicDashboard.useQueryState(
dashboard.state.uid!
);
return (
<Field
label={t('public-dashboard.email-sharing.recipient-list-title', 'People with access')}
description={t(
'public-dashboard.email-sharing.recipient-list-description',
"Only people you've directly invited can access this dashboard"
)}
className={styles.listField}
>
{!!publicDashboard?.recipients?.length ? (
<div className={styles.listContainer}>
<EmailList
recipients={publicDashboard.recipients}
dashboardUid={dashboard.state.uid!}
publicDashboard={publicDashboard}
/>
</div>
) : (
<></>
)}
</Field>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
listField: css({
marginBottom: 0,
}),
listContainer: css({
maxHeight: '140px',
overflowY: 'auto',
}),
table: css({
width: '100%',
}),
listItem: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.5),
padding: theme.spacing(0.75, 1),
color: theme.colors.text.secondary,
}),
user: css({
flex: 1,
}),
icon: css({
border: `${theme.spacing(0.25)} solid ${theme.colors.text.secondary}`,
padding: theme.spacing(0.125, 0.5),
borderRadius: theme.shape.radius.circle,
color: theme.colors.text.secondary,
}),
});

View File

@@ -0,0 +1,68 @@
import { css } from '@emotion/css';
import React from 'react';
import { useForm } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Checkbox, FieldSet, Spinner, Stack } from '@grafana/ui';
import { useStyles2 } from '@grafana/ui/';
import { contextSrv } from 'app/core/core';
import { t, Trans } from 'app/core/internationalization';
import { useCreatePublicDashboardMutation } from 'app/features/dashboard/api/publicDashboardApi';
import { PublicDashboardShareType } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
import { AccessControlAction } from 'app/types';
import { EmailSharingPricingAlert } from '../../../../../dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/EmailSharingPricingAlert';
import { useShareDrawerContext } from '../../../ShareDrawer/ShareDrawerContext';
export const CreateEmailSharing = ({ hasError }: { hasError: boolean }) => {
const { dashboard } = useShareDrawerContext();
const styles = useStyles2(getStyles);
const [createPublicDashboard, { isLoading, isError }] = useCreatePublicDashboardMutation();
const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite);
const disableInputs = !hasWritePermissions || isLoading || isError || hasError;
const {
handleSubmit,
register,
formState: { isValid },
} = useForm<{ billAcknowledgment: boolean }>({ mode: 'onChange' });
const onCreate = () => {
DashboardInteractions.generatePublicDashboardUrlClicked({ share: PublicDashboardShareType.EMAIL });
createPublicDashboard({ dashboard, payload: { share: PublicDashboardShareType.EMAIL, isEnabled: true } });
};
return (
<>
{hasWritePermissions && <EmailSharingPricingAlert />}
<form onSubmit={handleSubmit(onCreate)}>
<FieldSet disabled={disableInputs}>
<div className={styles.checkbox}>
<Checkbox
{...register('billAcknowledgment', { required: true })}
label={t('public-dashboard.email-sharing.bill-ack', 'I understand that adding users requires payment.*')}
/>
</div>
<Stack direction="row" gap={1} alignItems="center">
<Button type="submit" disabled={!isValid}>
<Trans i18nKey="public-dashboard.email-sharing.accept-button">Accept</Trans>
</Button>
<Button variant="secondary" onClick={() => dashboard.closeModal()}>
<Trans i18nKey="public-dashboard.email-sharing.cancel-button">Cancel</Trans>
</Button>
{isLoading && <Spinner />}
</Stack>
</FieldSet>
</form>
</>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
checkbox: css({
marginBottom: theme.spacing(2),
}),
});

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi';
import { useShareDrawerContext } from '../../../ShareDrawer/ShareDrawerContext';
import { ConfigEmailSharing } from './ConfigEmailSharing/ConfigEmailSharing';
import { CreateEmailSharing } from './CreateEmailSharing';
export const EmailSharing = () => {
const { dashboard } = useShareDrawerContext();
const { data: publicDashboard, isError } = publicDashboardApi.endpoints?.getPublicDashboard.useQueryState(
dashboard.state.uid!
);
return <>{!publicDashboard ? <CreateEmailSharing hasError={isError} /> : <ConfigEmailSharing />}</>;
};

View File

@@ -0,0 +1,70 @@
import { css } from '@emotion/css';
import React from 'react';
import { useForm } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Checkbox, FieldSet, Spinner, Stack, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { t, Trans } from 'app/core/internationalization';
import { useCreatePublicDashboardMutation } from 'app/features/dashboard/api/publicDashboardApi';
import { PublicDashboardShareType } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
import { AccessControlAction } from 'app/types';
import { PublicDashboardAlert } from '../../../../../dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/PublicDashboardAlert';
import { useShareDrawerContext } from '../../../ShareDrawer/ShareDrawerContext';
export default function CreatePublicSharing({ hasError }: { hasError: boolean }) {
const { dashboard } = useShareDrawerContext();
const styles = useStyles2(getStyles);
const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite);
const {
handleSubmit,
register,
formState: { isValid },
} = useForm<{ publicAcknowledgment: boolean }>({ mode: 'onChange' });
const [createPublicDashboard, { isLoading, isError }] = useCreatePublicDashboardMutation();
const onCreate = () => {
DashboardInteractions.generatePublicDashboardUrlClicked({ share: PublicDashboardShareType.PUBLIC });
createPublicDashboard({ dashboard, payload: { share: PublicDashboardShareType.PUBLIC, isEnabled: true } });
};
const disableInputs = !hasWritePermissions || isLoading || isError || hasError;
return (
<>
{hasWritePermissions && <PublicDashboardAlert />}
<form onSubmit={handleSubmit(onCreate)}>
<FieldSet disabled={disableInputs}>
<div className={styles.checkbox}>
<Checkbox
{...register('publicAcknowledgment', { required: true })}
label={t(
'public-dashboard.public-sharing.public-ack',
'I understand that this entire dashboard will be public.*'
)}
/>
</div>
<Stack direction="row" gap={1} alignItems="center">
<Button type="submit" disabled={!isValid}>
<Trans i18nKey="public-dashboard.public-sharing.accept-button">Accept</Trans>
</Button>
<Button variant="secondary" onClick={() => dashboard.closeModal()}>
<Trans i18nKey="public-dashboard.public-sharing.cancel-button">Cancel</Trans>
</Button>
{isLoading && <Spinner />}
</Stack>
</FieldSet>
</form>
</>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
checkbox: css({
marginBottom: theme.spacing(2),
}),
});

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi';
import { useShareDrawerContext } from '../../../ShareDrawer/ShareDrawerContext';
import ShareConfiguration from '../ShareConfiguration';
import CreatePublicSharing from './CreatePublicSharing';
export function PublicSharing() {
const { dashboard } = useShareDrawerContext();
const { data: publicDashboard, isError } = publicDashboardApi.endpoints?.getPublicDashboard.useQueryState(
dashboard.state.uid!
);
return <>{!publicDashboard ? <CreatePublicSharing hasError={isError} /> : <ShareConfiguration />}</>;
}

View File

@@ -0,0 +1,118 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import {
CustomVariable,
SceneDataTransformer,
SceneGridLayout,
SceneQueryRunner,
SceneTimeRange,
SceneVariableSet,
VizPanel,
VizPanelState,
} from '@grafana/scenes';
import { contextSrv } from 'app/core/core';
import { DashboardGridItem } from 'app/features/dashboard-scene/scene/DashboardGridItem';
import { DashboardScene, DashboardSceneState } from 'app/features/dashboard-scene/scene/DashboardScene';
import { ShareDrawerContext } from '../../ShareDrawer/ShareDrawerContext';
import ShareAlerts from './ShareAlerts';
const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard;
beforeEach(() => {
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
});
describe('ShareAlerts', () => {
describe('UnsupportedTemplateVariablesAlert', () => {
it('should render alert when hasPermission and the dashboard has template vars', async () => {
await setup(undefined, {
$variables: new SceneVariableSet({
variables: [
new CustomVariable({
name: 'customVar',
query: 'test, test2',
value: 'test',
text: 'test',
}),
],
}),
});
expect(await screen.findByTestId(selectors.TemplateVariablesWarningAlert)).toBeInTheDocument();
});
it('should not render alert when hasPermission but the dashboard has no template vars', async () => {
await setup();
expect(screen.queryByTestId(selectors.TemplateVariablesWarningAlert)).not.toBeInTheDocument();
});
});
describe('UnsupportedDataSourcesAlert', () => {
it('should render alert when hasPermission and the dashboard has unsupported ds', async () => {
await setup({
$data: new SceneDataTransformer({
transformations: [],
$data: new SceneQueryRunner({
datasource: { uid: 'abcdef' },
queries: [{ refId: 'A', datasource: { type: 'abcdef' } }],
}),
}),
});
expect(await screen.findByTestId(selectors.UnsupportedDataSourcesWarningAlert)).toBeInTheDocument();
});
it('should not render alert when hasPermission but the dashboard has no unsupported ds', async () => {
await setup({
$data: new SceneDataTransformer({
transformations: [],
$data: new SceneQueryRunner({
datasource: { uid: 'prometheus' },
queries: [{ refId: 'A', datasource: { type: 'prometheus' } }],
}),
}),
});
expect(screen.queryByTestId(selectors.UnsupportedDataSourcesWarningAlert)).not.toBeInTheDocument();
});
});
});
async function setup(panelState?: Partial<VizPanelState>, dashboardState?: Partial<DashboardSceneState>) {
const panel = new VizPanel({
title: 'Panel A',
pluginId: 'table',
key: 'panel-12',
...panelState,
});
const dashboard = new DashboardScene({
title: 'hello',
uid: 'dash-1',
$timeRange: new SceneTimeRange({}),
body: new SceneGridLayout({
children: [
new DashboardGridItem({
key: 'griditem-1',
x: 0,
y: 0,
width: 10,
height: 12,
body: panel,
}),
],
}),
...dashboardState,
});
await act(async () =>
render(
<ShareDrawerContext.Provider value={{ dashboard }}>
<ShareAlerts />
</ShareDrawerContext.Provider>
)
);
}

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { contextSrv } from 'app/core/core';
import { EmailSharingPricingAlert } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/EmailSharingPricingAlert';
import { UnsupportedDataSourcesAlert } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedDataSourcesAlert';
import { UnsupportedTemplateVariablesAlert } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/UnsupportedTemplateVariablesAlert';
import {
isEmailSharingEnabled,
PublicDashboard,
PublicDashboardShareType,
} from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
import { AccessControlAction } from 'app/types';
import { NoUpsertPermissionsAlert } from '../../../../dashboard/components/ShareModal/SharePublicDashboard/ModalAlerts/NoUpsertPermissionsAlert';
import { useShareDrawerContext } from '../../ShareDrawer/ShareDrawerContext';
import { useUnsupportedDatasources } from '../../public-dashboards/hooks';
export default function ShareAlerts({ publicDashboard }: { publicDashboard?: PublicDashboard }) {
const { dashboard } = useShareDrawerContext();
const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite);
const unsupportedDataSources = useUnsupportedDatasources(dashboard);
const hasTemplateVariables = (dashboard.state.$variables?.state.variables.length ?? 0) > 0;
return (
<>
{hasWritePermissions && hasTemplateVariables && <UnsupportedTemplateVariablesAlert showDescription={false} />}
{!hasWritePermissions && <NoUpsertPermissionsAlert mode={publicDashboard ? 'edit' : 'create'} />}
{hasWritePermissions && !!unsupportedDataSources?.length && (
<UnsupportedDataSourcesAlert unsupportedDataSources={unsupportedDataSources.join(', ')} />
)}
{publicDashboard?.share === PublicDashboardShareType.EMAIL && isEmailSharingEnabled() && (
<EmailSharingPricingAlert />
)}
</>
);
}

View File

@@ -0,0 +1,131 @@
import React from 'react';
import { Controller, useForm } from 'react-hook-form';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { sceneGraph } from '@grafana/scenes';
import { FieldSet, Icon, Label, Spinner, Stack, Text, TimeRangeInput, Tooltip } from '@grafana/ui';
import { Switch } from '@grafana/ui/src/components/Switch/Switch';
import { contextSrv } from 'app/core/core';
import { Trans, t } from 'app/core/internationalization';
import { publicDashboardApi, useUpdatePublicDashboardMutation } from 'app/features/dashboard/api/publicDashboardApi';
import { ConfigPublicDashboardForm } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/ConfigPublicDashboard/ConfigPublicDashboard';
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
import { AccessControlAction } from 'app/types';
import { useShareDrawerContext } from '../../ShareDrawer/ShareDrawerContext';
const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard;
type FormInput = Omit<ConfigPublicDashboardForm, 'isPaused'>;
export default function ShareConfiguration() {
const { dashboard } = useShareDrawerContext();
const [update, { isLoading }] = useUpdatePublicDashboardMutation();
const { data: publicDashboard } = publicDashboardApi.endpoints?.getPublicDashboard.useQueryState(
dashboard.state.uid!
);
const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite);
const disableForm = isLoading || !hasWritePermissions;
const timeRangeState = sceneGraph.getTimeRange(dashboard);
const timeRange = timeRangeState.useState();
const { handleSubmit, setValue, control } = useForm<FormInput>({
defaultValues: {
isAnnotationsEnabled: publicDashboard?.annotationsEnabled,
isTimeSelectionEnabled: publicDashboard?.timeSelectionEnabled,
},
});
const onChange = async (name: keyof FormInput, value: boolean) => {
setValue(name, value);
await handleSubmit((data) => onUpdate({ ...data, [name]: value }))();
};
const onUpdate = (data: FormInput) => {
const { isAnnotationsEnabled, isTimeSelectionEnabled } = data;
update({
dashboard: dashboard,
payload: {
...publicDashboard!,
annotationsEnabled: isAnnotationsEnabled,
timeSelectionEnabled: isTimeSelectionEnabled,
},
});
};
return (
<Stack direction="column" gap={2}>
<Text element="p">
<Trans i18nKey="public-dashboard.configuration.settings-label">Settings</Trans>
</Text>
<Stack justifyContent="space-between">
<form onSubmit={handleSubmit(onUpdate)}>
<FieldSet disabled={disableForm}>
<Stack direction="column" gap={2}>
<Stack gap={1}>
<Controller
render={({ field: { ref, ...field } }) => (
<Switch
{...field}
data-testid={selectors.EnableTimeRangeSwitch}
onChange={(e) => {
DashboardInteractions.publicDashboardTimeSelectionChanged({
enabled: e.currentTarget.checked,
});
onChange('isTimeSelectionEnabled', e.currentTarget.checked);
}}
label={t('public-dashboard.configuration.enable-time-range-label', 'Enable time range')}
/>
)}
control={control}
name="isTimeSelectionEnabled"
/>
<Label description="Allow people to change time range">
<Trans i18nKey="public-dashboard.configuration.enable-time-range-label">Enable time range</Trans>
</Label>
</Stack>
<Stack gap={1}>
<Controller
render={({ field: { ref, ...field } }) => (
<Switch
{...field}
data-testid={selectors.EnableAnnotationsSwitch}
onChange={(e) => {
DashboardInteractions.publicDashboardAnnotationsSelectionChanged({
enabled: e.currentTarget.checked,
});
onChange('isAnnotationsEnabled', e.currentTarget.checked);
}}
label={t('public-dashboard.configuration.display-annotations-label', 'Display annotations')}
/>
)}
control={control}
name="isAnnotationsEnabled"
/>
<Label style={{ flex: 1 }} description="Present annotations on this Dashboard">
<Trans i18nKey="public-dashboard.configuration.display-annotations-label">Display annotations</Trans>
</Label>
</Stack>
<Stack gap={1} alignItems="center">
<TimeRangeInput value={timeRange.value} showIcon disabled onChange={() => {}} />
<Tooltip
placement="right"
content={t(
'public-dashboard.configuration.time-range-tooltip',
'The shared dashboard uses the default time range settings of the dashboard'
)}
>
<Icon name="info-circle" size="sm" />
</Tooltip>
</Stack>
</Stack>
</FieldSet>
</form>
{isLoading && <Spinner />}
</Stack>
</Stack>
);
}

View File

@@ -0,0 +1,212 @@
import { css } from '@emotion/css';
import React, { useMemo, useState } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes';
import { Button, ClipboardButton, Divider, Spinner, Stack, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { t, Trans } from 'app/core/internationalization';
import {
useDeletePublicDashboardMutation,
useGetPublicDashboardQuery,
usePauseOrResumePublicDashboardMutation,
} from 'app/features/dashboard/api/publicDashboardApi';
import { Loader } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard';
import {
generatePublicDashboardUrl,
isEmailSharingEnabled,
PublicDashboard,
PublicDashboardShareType,
} from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
import { AccessControlAction } from 'app/types';
import { getDashboardSceneFor } from '../../../utils/utils';
import { useShareDrawerContext } from '../../ShareDrawer/ShareDrawerContext';
import { EmailSharing } from './EmailShare/EmailSharing';
import { PublicSharing } from './PublicShare/PublicSharing';
import ShareAlerts from './ShareAlerts';
import ShareTypeSelect from './ShareTypeSelect';
const selectors = e2eSelectors.pages.ShareDashboardDrawer.ShareExternally;
export const getAnyOneWithTheLinkShareOption = () => {
return {
label: t('public-dashboard.share-externally.public-share-type-option-label', 'Anyone with the link'),
description: t(
'public-dashboard.share-externally.public-share-type-option-description',
'Anyone with the link can access'
),
value: PublicDashboardShareType.PUBLIC,
icon: 'globe',
};
};
const getOnlySpecificPeopleShareOption = () => ({
label: t('public-dashboard.share-externally.email-share-type-option-label', 'Only specific people'),
description: t(
'public-dashboard.share-externally.email-share-type-option-description',
'Only people with access can open with the link'
),
value: PublicDashboardShareType.EMAIL,
icon: 'users-alt',
});
const getShareExternallyOptions = () => {
return isEmailSharingEnabled()
? [getOnlySpecificPeopleShareOption(), getAnyOneWithTheLinkShareOption()]
: [getAnyOneWithTheLinkShareOption()];
};
export class ShareExternally extends SceneObjectBase {
static Component = ShareExternallyRenderer;
}
function ShareExternallyRenderer({ model }: SceneComponentProps<ShareExternally>) {
const dashboard = getDashboardSceneFor(model);
const { data: publicDashboard, isLoading } = useGetPublicDashboardQuery(dashboard.state.uid!);
const styles = useStyles2(getStyles);
if (isLoading) {
return <Loader />;
}
return (
<div className={styles.container}>
<ShareExternallyBase publicDashboard={publicDashboard} />
</div>
);
}
function ShareExternallyBase({ publicDashboard }: { publicDashboard?: PublicDashboard }) {
const options = getShareExternallyOptions();
const getShareType = useMemo(() => {
if (publicDashboard && isEmailSharingEnabled()) {
const opt = options.find((opt) => opt.value === publicDashboard?.share)!;
return opt ?? options[0];
}
return options[0];
}, [publicDashboard, options]);
const [shareType, setShareType] = useState<SelectableValue<PublicDashboardShareType>>(getShareType);
const Config = useMemo(() => {
if (shareType.value === PublicDashboardShareType.EMAIL && isEmailSharingEnabled()) {
return <EmailSharing />;
}
return <PublicSharing />;
}, [shareType]);
return (
<Stack direction="column" gap={2} data-testid={selectors.container}>
<ShareAlerts publicDashboard={publicDashboard} />
<ShareTypeSelect setShareType={setShareType} value={shareType} options={options} />
{Config}
{publicDashboard && (
<>
<Divider spacing={0} />
<Actions publicDashboard={publicDashboard} />
</>
)}
</Stack>
);
}
function Actions({ publicDashboard }: { publicDashboard: PublicDashboard }) {
const { dashboard } = useShareDrawerContext();
const [update, { isLoading: isUpdateLoading }] = usePauseOrResumePublicDashboardMutation();
const [deletePublicDashboard, { isLoading: isDeleteLoading }] = useDeletePublicDashboardMutation();
const styles = useStyles2(getStyles);
const isLoading = isUpdateLoading || isDeleteLoading;
const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite);
function onCopyURL() {
DashboardInteractions.publicDashboardUrlCopied();
}
const onPauseOrResumeClick = async () => {
DashboardInteractions.publicDashboardPauseSharingClicked({
paused: !publicDashboard.isEnabled,
});
update({
dashboard: dashboard,
payload: {
...publicDashboard!,
isEnabled: !publicDashboard.isEnabled,
},
});
};
const onDeleteClick = () => {
DashboardInteractions.revokePublicDashboardClicked();
deletePublicDashboard({
dashboard,
uid: publicDashboard!.uid,
dashboardUid: dashboard.state.uid!,
});
};
return (
<Stack alignItems="center" direction={{ xs: 'column', sm: 'row' }}>
<div className={styles.actionsContainer}>
<Stack gap={1} flex={1} direction={{ xs: 'column', sm: 'row' }}>
<ClipboardButton
data-testid={selectors.copyUrlButton}
variant="primary"
fill="outline"
icon="link"
getText={() => generatePublicDashboardUrl(publicDashboard!.accessToken!)}
onClipboardCopy={onCopyURL}
>
<Trans i18nKey="public-dashboard.share-externally.copy-link-button">Copy external link</Trans>
</ClipboardButton>
<Button
icon="trash-alt"
variant="destructive"
fill="outline"
disabled={isLoading || !hasWritePermissions}
onClick={onDeleteClick}
>
<Trans i18nKey="public-dashboard.share-externally.revoke-access-button">Revoke access</Trans>
</Button>
<Button
icon={publicDashboard.isEnabled ? 'pause' : 'play'}
variant="secondary"
fill="outline"
tooltip={
publicDashboard.isEnabled
? t(
'public-dashboard.share-externally.pause-access-tooltip',
'Pausing will temporarily disable access to this dashboard for all users'
)
: ''
}
onClick={onPauseOrResumeClick}
disabled={isLoading || !hasWritePermissions}
>
{publicDashboard.isEnabled ? (
<Trans i18nKey="public-dashboard.share-externally.pause-access-button">Pause access</Trans>
) : (
<Trans i18nKey="public-dashboard.share-externally.resume-access-button">Resume access</Trans>
)}
</Button>
</Stack>
</div>
{isLoading && <Spinner />}
</Stack>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
paddingBottom: theme.spacing(2),
}),
actionsContainer: css({
width: '100%',
}),
});

View File

@@ -0,0 +1,106 @@
import { css } from '@emotion/css';
import React from 'react';
import { SelectableValue, toIconName } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { Icon, Label, Select, Spinner, Stack, Text, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { Trans } from 'app/core/internationalization';
import {
publicDashboardApi,
useUpdatePublicDashboardAccessMutation,
} from 'app/features/dashboard/api/publicDashboardApi';
import {
isEmailSharingEnabled,
PublicDashboardShareType,
} from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
import { AccessControlAction } from 'app/types';
import { useShareDrawerContext } from '../../ShareDrawer/ShareDrawerContext';
import { getAnyOneWithTheLinkShareOption } from './ShareExternally';
const selectors = e2eSelectors.pages.ShareDashboardDrawer.ShareExternally;
export default function ShareTypeSelect({
setShareType,
options,
value,
}: {
setShareType: (v: SelectableValue<PublicDashboardShareType>) => void;
value: SelectableValue<PublicDashboardShareType>;
options: Array<SelectableValue<PublicDashboardShareType>>;
}) {
const { dashboard } = useShareDrawerContext();
const styles = useStyles2(getStyles);
const { data: publicDashboard } = publicDashboardApi.endpoints?.getPublicDashboard.useQueryState(
dashboard.state.uid!
);
const [updateAccess, { isLoading }] = useUpdatePublicDashboardAccessMutation();
const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite);
const anyOneWithTheLinkOpt = getAnyOneWithTheLinkShareOption();
const onUpdateShareType = (shareType: PublicDashboardShareType) => {
if (!publicDashboard) {
return;
}
DashboardInteractions.publicDashboardShareTypeChange({
shareType: shareType === PublicDashboardShareType.EMAIL ? 'email' : 'public',
});
const req = {
dashboard,
payload: {
...publicDashboard!,
share: shareType,
},
};
updateAccess(req);
};
return (
<div>
<Stack justifyContent="space-between">
<Label description={value.description}>
<Trans i18nKey="public-dashboard.share-configuration.share-type-label">Link access</Trans>
</Label>
{isLoading && <Spinner />}
</Stack>
<Stack direction="row" gap={1} alignItems="center">
{isEmailSharingEnabled() ? (
<Select
data-testid={selectors.shareTypeSelect}
options={options}
value={value}
disabled={!hasWritePermissions}
onChange={(v) => {
setShareType(v);
onUpdateShareType(v.value!);
}}
className={styles.select}
/>
) : (
<>
{toIconName(anyOneWithTheLinkOpt.icon) && <Icon name={toIconName(anyOneWithTheLinkOpt.icon)!} />}
<Text>{anyOneWithTheLinkOpt.label}</Text>
</>
)}
<Text element="p" variant="bodySmall" color="disabled">
<Trans i18nKey="public-dashboard.share-configuration.access-label">can access</Trans>
</Text>
</Stack>
</div>
);
}
const getStyles = () => {
return {
select: css({
flex: 1,
}),
};
};

View File

@@ -0,0 +1,16 @@
import { VizPanel } from '@grafana/scenes';
import { createAndCopyDashboardShortLink } from 'app/core/utils/shortLinks';
import { getTrackingSource } from 'app/features/dashboard/components/ShareModal/utils';
import { DashboardScene } from '../../scene/DashboardScene';
import { DashboardInteractions } from '../../utils/interactions';
export const buildShareUrl = async (dashboard: DashboardScene, panel?: VizPanel) => {
DashboardInteractions.shareLinkCopied({
currentTimeRange: true,
theme: 'current',
shortenURL: true,
shareResource: getTrackingSource(panel?.getRef()),
});
return await createAndCopyDashboardShortLink(dashboard, { useAbsoluteTimeRange: true, theme: 'current' });
};

View File

@@ -0,0 +1,42 @@
import React from 'react';
import {
SceneComponentProps,
SceneObject,
SceneObjectBase,
SceneObjectRef,
SceneObjectState,
VizPanel,
} from '@grafana/scenes';
import { Drawer } from '@grafana/ui';
import { getDashboardSceneFor } from '../../utils/utils';
import { ModalSceneObjectLike } from '../types';
import { ShareDrawerContext } from './ShareDrawerContext';
export interface ShareDrawerState extends SceneObjectState {
title: string;
panelRef?: SceneObjectRef<VizPanel>;
body: SceneObject;
}
export class ShareDrawer extends SceneObjectBase<ShareDrawerState> implements ModalSceneObjectLike {
static Component = ShareDrawerRenderer;
onDismiss = () => {
const dashboard = getDashboardSceneFor(this);
dashboard.closeModal();
};
}
function ShareDrawerRenderer({ model }: SceneComponentProps<ShareDrawer>) {
const { title, body } = model.useState();
const dashboard = getDashboardSceneFor(model);
return (
<Drawer title={title} onClose={model.onDismiss} size="md">
<ShareDrawerContext.Provider value={{ dashboard }}>{<body.Component model={body} />}</ShareDrawerContext.Provider>
</Drawer>
);
}

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { DashboardScene } from '../../scene/DashboardScene';
interface Context {
dashboard: DashboardScene;
}
export const ShareDrawerContext = React.createContext<Context | undefined>(undefined);
const useShareDrawerContext = () => {
const context = React.useContext(ShareDrawerContext);
if (context === undefined) {
throw new Error('useShareDrawerContext must be used within a DrawerContext');
}
return context;
};
export { useShareDrawerContext };

View File

@@ -1,12 +1,14 @@
import { BaseQueryFn, createApi } from '@reduxjs/toolkit/query/react';
import { lastValueFrom } from 'rxjs';
import { BackendSrvRequest, FetchError, getBackendSrv, isFetchError } from '@grafana/runtime/src';
import { BackendSrvRequest, config, FetchError, getBackendSrv, isFetchError } from '@grafana/runtime/src';
import { notifyApp } from 'app/core/actions';
import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification';
import { t } from 'app/core/internationalization';
import {
PublicDashboard,
PublicDashboardSettings,
PublicDashboardShareType,
SessionDashboard,
SessionUser,
} from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
@@ -43,7 +45,9 @@ const backendSrvBaseQuery =
}
};
const getConfigError = (err: unknown) => ({ error: isFetchError(err) && err.status !== 404 ? err : null });
const getConfigError = (err: unknown) => ({
error: isFetchError(err) && err.data.messageId !== 'publicdashboards.notFound' ? err : null,
});
export const publicDashboardApi = createApi({
reducerPath: 'publicDashboardApi',
@@ -80,16 +84,22 @@ export const publicDashboardApi = createApi({
data: params.payload,
};
},
async onQueryStarted({ dashboard, payload }, { dispatch, queryFulfilled }) {
async onQueryStarted({ dashboard, payload: { share } }, { dispatch, queryFulfilled }) {
const { data } = await queryFulfilled;
dispatch(notifyApp(createSuccessNotification('Dashboard is public!')));
let message = t('public-dashboard.sharing.success-creation', 'Dashboard is public!');
if (config.featureToggles.newDashboardSharingComponent) {
message =
share === PublicDashboardShareType.PUBLIC
? t('public-dashboard.public-sharing.success-creation', 'Your dashboard is now publicly accessible')
: t('public-dashboard.email-sharing.success-creation', 'Your dashboard is ready for external sharing');
}
dispatch(notifyApp(createSuccessNotification(message)));
if (dashboard instanceof DashboardScene) {
dashboard.setState({
meta: { ...dashboard.state.meta, publicDashboardEnabled: data.isEnabled },
});
} else {
// Update runtime meta flag
dashboard.updateMeta({
publicDashboardEnabled: data.isEnabled,
});
@@ -115,8 +125,46 @@ export const publicDashboardApi = createApi({
};
},
async onQueryStarted({ dashboard }, { dispatch, queryFulfilled }) {
await queryFulfilled;
dispatch(
notifyApp(
createSuccessNotification(
config.featureToggles.newDashboardSharingComponent
? t('public-dashboard.configuration.success-update', 'Settings have been successfully updated')
: t('public-dashboard.configuration.success-update-old', 'Public dashboard updated!')
)
)
);
},
invalidatesTags: (result, error, { payload }) => [
{ type: 'PublicDashboard', id: payload.dashboardUid },
'AuditTablePublicDashboard',
],
}),
pauseOrResumePublicDashboard: builder.mutation<
PublicDashboard,
{
dashboard: (Pick<DashboardModel, 'uid'> & Partial<Pick<DashboardModel, 'updateMeta'>>) | DashboardScene;
payload: Partial<PublicDashboard>;
}
>({
query: ({ payload, dashboard }) => {
const dashUid = dashboard instanceof DashboardScene ? dashboard.state.uid : dashboard.uid;
return {
url: `/dashboards/uid/${dashUid}/public-dashboards/${payload.uid}`,
method: 'PATCH',
data: payload,
};
},
async onQueryStarted({ dashboard, payload: { isEnabled } }, { dispatch, queryFulfilled }) {
const { data } = await queryFulfilled;
dispatch(notifyApp(createSuccessNotification('Public dashboard updated!')));
let message = t('public-dashboard.configuration.success-update-old', 'Public dashboard updated!');
if (config.featureToggles.newDashboardSharingComponent) {
message = isEnabled
? t('public-dashboard.configuration.success-resume', 'Your dashboard access has been resumed')
: t('public-dashboard.configuration.success-pause', 'Your dashboard access has been paused');
}
dispatch(notifyApp(createSuccessNotification(message)));
if (dashboard instanceof DashboardScene) {
dashboard.setState({
@@ -133,6 +181,44 @@ export const publicDashboardApi = createApi({
'AuditTablePublicDashboard',
],
}),
updatePublicDashboardAccess: builder.mutation<
PublicDashboard,
{
dashboard: (Pick<DashboardModel, 'uid'> & Partial<Pick<DashboardModel, 'updateMeta'>>) | DashboardScene;
payload: Partial<PublicDashboard>;
}
>({
query: ({ payload, dashboard }) => {
const dashUid = dashboard instanceof DashboardScene ? dashboard.state.uid : dashboard.uid;
return {
url: `/dashboards/uid/${dashUid}/public-dashboards/${payload.uid}`,
method: 'PATCH',
data: payload,
};
},
async onQueryStarted({ dashboard, payload: { share } }, { dispatch, queryFulfilled }) {
await queryFulfilled;
let message = t('public-dashboard.configuration.success-update-old', 'Public dashboard updated!');
if (config.featureToggles.newDashboardSharingComponent) {
message =
share === PublicDashboardShareType.PUBLIC
? t(
'public-dashboard.public-sharing.success-share-type-change',
'Dashboard access updated: Anyone with the link can now access'
)
: t(
'public-dashboard.email-sharing.success-share-type-change',
'Dashboard access restricted: Only specific people can now access with the link'
);
}
dispatch(notifyApp(createSuccessNotification(message)));
},
invalidatesTags: (result, error, { payload }) => [
{ type: 'PublicDashboard', id: payload.dashboardUid },
'AuditTablePublicDashboard',
],
}),
addRecipient: builder.mutation<void, { recipient: string; dashboardUid: string; uid: string }>({
query: () => ({
url: '',
@@ -183,7 +269,16 @@ export const publicDashboardApi = createApi({
}),
async onQueryStarted({ dashboard }, { dispatch, queryFulfilled }) {
await queryFulfilled;
dispatch(notifyApp(createSuccessNotification('Public dashboard deleted!')));
dispatch(
notifyApp(
createSuccessNotification(
config.featureToggles.newDashboardSharingComponent
? t('public-dashboard.share.success-delete', 'Your dashboard is no longer shareable')
: t('public-dashboard.share.success-delete-old', 'Public dashboard deleted!')
)
)
);
dispatch(publicDashboardApi.util?.resetApiState());
if (dashboard instanceof DashboardScene) {
dashboard.setState({
@@ -222,4 +317,6 @@ export const {
useGetActiveUsersQuery,
useGetActiveUserDashboardsQuery,
useRevokeAllAccessMutation,
usePauseOrResumePublicDashboardMutation,
useUpdatePublicDashboardAccessMutation,
} = publicDashboardApi;

View File

@@ -4,7 +4,6 @@ import { useForm } from 'react-hook-form';
import { GrafanaTheme2, TimeRange } from '@grafana/data/src';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
import { config, featureEnabled } from '@grafana/runtime/src';
import {
Button,
ClipboardButton,
@@ -20,6 +19,7 @@ import { Layout } from '@grafana/ui/src/components/Layout/Layout';
import { Trans, t } from 'app/core/internationalization';
import {
useDeletePublicDashboardMutation,
usePauseOrResumePublicDashboardMutation,
useUpdatePublicDashboardMutation,
} from 'app/features/dashboard/api/publicDashboardApi';
import { DashboardModel } from 'app/features/dashboard/state';
@@ -40,6 +40,7 @@ import { UnsupportedTemplateVariablesAlert } from '../ModalAlerts/UnsupportedTem
import {
dashboardHasTemplateVariables,
generatePublicDashboardUrl,
isEmailSharingEnabled,
PublicDashboard,
} from '../SharePublicDashboardUtils';
@@ -79,10 +80,9 @@ export function ConfigPublicDashboardBase({
const isDesktop = useIsDesktop();
const [update, { isLoading }] = useUpdatePublicDashboardMutation();
const [pauseOrResume, { isLoading: isPauseOrResumeLoading }] = usePauseOrResumePublicDashboardMutation();
const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite);
const disableInputs = !hasWritePermissions || isLoading;
const hasEmailSharingEnabled =
!!config.featureToggles.publicDashboardsEmailSharing && featureEnabled('publicDashboardsEmailSharing');
const disableInputs = !hasWritePermissions || isLoading || isPauseOrResumeLoading;
const { handleSubmit, setValue, register } = useForm<ConfigPublicDashboardForm>({
defaultValues: {
@@ -105,12 +105,30 @@ export function ConfigPublicDashboardBase({
},
});
};
const onPauseOrResume = async (values: ConfigPublicDashboardForm) => {
const { isAnnotationsEnabled, isTimeSelectionEnabled, isPaused } = values;
pauseOrResume({
dashboard: dashboard,
payload: {
...publicDashboard!,
annotationsEnabled: isAnnotationsEnabled,
timeSelectionEnabled: isTimeSelectionEnabled,
isEnabled: !isPaused,
},
});
};
const onChange = async (name: keyof ConfigPublicDashboardForm, value: boolean) => {
setValue(name, value);
await handleSubmit((data) => onPublicDashboardUpdate(data))();
};
const onTogglePause = async (value: boolean) => {
setValue('isPaused', value);
await handleSubmit((data) => onPauseOrResume(data))();
};
function onCopyURL() {
DashboardInteractions.publicDashboardUrlCopied();
}
@@ -124,7 +142,7 @@ export function ConfigPublicDashboardBase({
<UnsupportedDataSourcesAlert unsupportedDataSources={unsupportedDatasources.join(', ')} />
)}
{hasEmailSharingEnabled && <EmailSharingConfiguration dashboard={dashboard} />}
{isEmailSharingEnabled() && <EmailSharingConfiguration dashboard={dashboard} />}
<Field
label={t('public-dashboard.config.dashboard-url-field-label', 'Dashboard URL')}
@@ -158,7 +176,7 @@ export function ConfigPublicDashboardBase({
DashboardInteractions.publicDashboardPauseSharingClicked({
paused: e.currentTarget.checked,
});
onChange('isPaused', e.currentTarget.checked);
onTogglePause(e.currentTarget.checked);
}}
data-testid={selectors.PauseSwitch}
/>

View File

@@ -13,7 +13,7 @@ import {
useDeleteRecipientMutation,
useGetPublicDashboardQuery,
useReshareAccessToRecipientMutation,
useUpdatePublicDashboardMutation,
useUpdatePublicDashboardAccessMutation,
} from 'app/features/dashboard/api/publicDashboardApi';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
@@ -101,7 +101,7 @@ export const EmailSharingConfiguration = ({ dashboard }: { dashboard: DashboardM
const dashboardUid = dashboard instanceof DashboardScene ? dashboard.state.uid : dashboard.uid;
const { data: publicDashboard } = useGetPublicDashboardQuery(dashboardUid);
const [updateShareType] = useUpdatePublicDashboardMutation();
const [updateShareType] = useUpdatePublicDashboardAccessMutation();
const [addEmail, { isLoading: isAddEmailLoading }] = useAddRecipientMutation();
const hasWritePermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPublicWrite);

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { Alert, Button, Stack } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
const EMAIL_SHARING_URL = 'https://grafana.com/docs/grafana/latest/dashboards/dashboard-public/#email-sharing';
export function EmailSharingPricingAlert() {
return (
<Alert title="" severity="info" bottomSpacing={0}>
<Stack justifyContent="space-between" gap={2} alignItems="center">
<Trans i18nKey="public-dashboard.email-sharing.alert-text">
Effective immediately, sharing public dashboards by email incurs a cost per active user. Going forward, youll
be prompted for payment whenever you add new users to your dashboard.
</Trans>
<Button variant="secondary" onClick={() => window.open(EMAIL_SHARING_URL, '_blank')} type="button">
<Trans i18nKey="public-dashboard.email-sharing.learn-more-button">Learn more</Trans>
</Button>
</Stack>
</Alert>
);
}

View File

@@ -8,7 +8,7 @@ const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard;
export const NoUpsertPermissionsAlert = ({ mode }: { mode: 'create' | 'edit' }) => (
<Alert
severity="info"
severity="warning"
title={t(
'public-dashboard.modal-alerts.no-upsert-perm-alert-title',
'You dont have permission to {{ mode }} a public dashboard',

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { Alert, Button, Stack } from '@grafana/ui';
import { Trans } from '../../../../../../core/internationalization';
const PUBLIC_DASHBOARD_URL = 'https://grafana.com/docs/grafana/latest/dashboards/dashboard-public/';
export const PublicDashboardAlert = () => (
<Alert title="" severity="info" bottomSpacing={0}>
<Stack justifyContent="space-between" gap={2} alignItems="center">
<Trans i18nKey="public-dashboard.public-sharing.alert-text">
Sharing this dashboard externally makes it entirely accessible to anyone with the link.
</Trans>
<Button variant="secondary" onClick={() => window.open(PUBLIC_DASHBOARD_URL, '_blank')} type="button">
<Trans i18nKey="public-dashboard.public-sharing.learn-more-button">Learn more</Trans>
</Button>
</Stack>
</Alert>
);

View File

@@ -6,7 +6,7 @@ import { Trans, t } from 'app/core/internationalization';
const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard;
export const UnsupportedTemplateVariablesAlert = () => (
export const UnsupportedTemplateVariablesAlert = ({ showDescription = true }: { showDescription?: boolean }) => (
<Alert
severity="warning"
title={t(
@@ -16,8 +16,10 @@ export const UnsupportedTemplateVariablesAlert = () => (
data-testid={selectors.TemplateVariablesWarningAlert}
bottomSpacing={0}
>
<Trans i18nKey="public-dashboard.modal-alerts.unsupported-template-variable-alert-desc">
This public dashboard may not work since it uses template variables
</Trans>
{showDescription && (
<Trans i18nKey="public-dashboard.modal-alerts.unsupported-template-variable-alert-desc">
This public dashboard may not work since it uses template variables
</Trans>
)}
</Alert>
);

View File

@@ -1,5 +1,5 @@
import { TypedVariableModel } from '@grafana/data';
import { config, DataSourceWithBackend } from '@grafana/runtime';
import { config, DataSourceWithBackend, featureEnabled } from '@grafana/runtime';
import { getConfig } from 'app/core/config';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
@@ -17,6 +17,7 @@ export interface PublicDashboardSettings {
annotationsEnabled: boolean;
isEnabled: boolean;
timeSelectionEnabled: boolean;
share: PublicDashboardShareType;
}
export interface PublicDashboard extends PublicDashboardSettings {
@@ -24,7 +25,6 @@ export interface PublicDashboard extends PublicDashboardSettings {
uid: string;
dashboardUid: string;
timeSettings?: object;
share: PublicDashboardShareType;
recipients?: Array<{ uid: string; recipient: string }>;
}
@@ -97,3 +97,8 @@ export const validEmailRegex = /^[A-Z\d._%+-]+@[A-Z\d.-]+\.[A-Z]{2,}$/i;
export const isPublicDashboardsEnabled = () => {
return Boolean(config.featureToggles.publicDashboards) && config.publicDashboardsEnabled;
};
export const isEmailSharingEnabled = () =>
isPublicDashboardsEnabled() &&
!!config.featureToggles.publicDashboardsEmailSharing &&
featureEnabled('publicDashboardsEmailSharing');

View File

@@ -1384,6 +1384,16 @@
"revoke-public-URL-button-title": "Revoke public URL",
"settings-title": "Settings"
},
"configuration": {
"display-annotations-label": "Display annotations",
"enable-time-range-label": "Enable time range",
"settings-label": "Settings",
"success-pause": "Your dashboard access has been paused",
"success-resume": "Your dashboard access has been resumed",
"success-update": "Settings have been successfully updated",
"success-update-old": "Public dashboard updated!",
"time-range-tooltip": "The shared dashboard uses the default time range settings of the dashboard"
},
"create-page": {
"generate-public-url-button": "Generate public URL",
"unsupported-features-desc": "Currently, we dont support template variables or frontend data sources",
@@ -1395,15 +1405,32 @@
"revoke-title": "Revoke public URL"
},
"email-sharing": {
"accept-button": "Accept",
"alert-text": "Effective immediately, sharing public dashboards by email incurs a cost per active user. Going forward, youll be prompted for payment whenever you add new users to your dashboard.",
"bill-ack": "I understand that adding users requires payment.*",
"cancel-button": "Cancel",
"input-invalid-email-text": "Invalid email",
"input-required-email-text": "Email is required",
"invite-button": "Invite",
"invite-field-desc": "Invite people by email",
"invite-field-label": "Invite",
"learn-more-button": "Learn more",
"recipient-email-placeholder": "Type in the recipient email address and press Enter",
"recipient-invalid-email-text": "Invalid email",
"recipient-invitation-button": "Invite",
"recipient-invitation-description": "Invite someone by email",
"recipient-invitation-tooltip": "This dashboard contains sensitive data. By using this feature you will be sharing with external people.",
"recipient-list-description": "Only people you've directly invited can access this dashboard",
"recipient-list-title": "People with access",
"recipient-required-email-text": "Email is required",
"resend-button": "Resend",
"resend-button-title": "Resend",
"resend-invite-label": "Resend invite",
"revoke-access-label": "Revoke access",
"revoke-button": "Revoke",
"revoke-button-title": "Revoke"
"revoke-button-title": "Revoke",
"success-creation": "Your dashboard is ready for external sharing",
"success-share-type-change": "Dashboard access restricted: Only specific people can now access with the link"
},
"modal-alerts": {
"no-upsert-perm-alert-desc": "Contact your admin to get permission to {{mode}} public dashboards",
@@ -1415,6 +1442,15 @@
"unsupported-template-variable-alert-desc": "This public dashboard may not work since it uses template variables",
"unsupported-template-variable-alert-title": "Template variables are not supported"
},
"public-sharing": {
"accept-button": "Accept",
"alert-text": "Sharing this dashboard externally makes it entirely accessible to anyone with the link.",
"cancel-button": "Cancel",
"learn-more-button": "Learn more",
"public-ack": "I understand that this entire dashboard will be public.*",
"success-creation": "Your dashboard is now publicly accessible",
"success-share-type-change": "Dashboard access updated: Anyone with the link can now access"
},
"settings-bar-header": {
"collapse-settings-tooltip": "Collapse settings",
"expand-settings-tooltip": "Expand settings"
@@ -1433,6 +1469,28 @@
"time-range-picker-disabled-text": "Time range picker = disabled",
"time-range-picker-enabled-text": "Time range picker = enabled",
"time-range-text": "Time range = "
},
"share": {
"success-delete": "Your dashboard is no longer shareable",
"success-delete-old": "Public dashboard deleted!"
},
"share-configuration": {
"access-label": "can access",
"share-type-label": "Link access"
},
"share-externally": {
"copy-link-button": "Copy external link",
"email-share-type-option-description": "Only people with access can open with the link",
"email-share-type-option-label": "Only specific people",
"pause-access-button": "Pause access",
"pause-access-tooltip": "Pausing will temporarily disable access to this dashboard for all users",
"public-share-type-option-description": "Anyone with the link can access",
"public-share-type-option-label": "Anyone with the link",
"resume-access-button": "Resume access",
"revoke-access-button": "Revoke access"
},
"sharing": {
"success-creation": "Dashboard is public!"
}
},
"public-dashboard-list": {

View File

@@ -1384,6 +1384,16 @@
"revoke-public-URL-button-title": "Ŗęvőĸę pūþľįč ŮŖĿ",
"settings-title": "Ŝęŧŧįʼnģş"
},
"configuration": {
"display-annotations-label": "Đįşpľäy äʼnʼnőŧäŧįőʼnş",
"enable-time-range-label": "Ēʼnäþľę ŧįmę řäʼnģę",
"settings-label": "Ŝęŧŧįʼnģş",
"success-pause": "Ÿőūř đäşĥþőäřđ äččęşş ĥäş þęęʼn päūşęđ",
"success-resume": "Ÿőūř đäşĥþőäřđ äččęşş ĥäş þęęʼn řęşūmęđ",
"success-update": "Ŝęŧŧįʼnģş ĥävę þęęʼn şūččęşşƒūľľy ūpđäŧęđ",
"success-update-old": "Pūþľįč đäşĥþőäřđ ūpđäŧęđ!",
"time-range-tooltip": "Ŧĥę şĥäřęđ đäşĥþőäřđ ūşęş ŧĥę đęƒäūľŧ ŧįmę řäʼnģę şęŧŧįʼnģş őƒ ŧĥę đäşĥþőäřđ"
},
"create-page": {
"generate-public-url-button": "Ğęʼnęřäŧę pūþľįč ŮŖĿ",
"unsupported-features-desc": "Cūřřęʼnŧľy, ŵę đőʼn’ŧ şūppőřŧ ŧęmpľäŧę väřįäþľęş őř ƒřőʼnŧęʼnđ đäŧä şőūřčęş",
@@ -1395,15 +1405,32 @@
"revoke-title": "Ŗęvőĸę pūþľįč ŮŖĿ"
},
"email-sharing": {
"accept-button": "Åččępŧ",
"alert-text": "Ēƒƒęčŧįvę įmmęđįäŧęľy, şĥäřįʼnģ pūþľįč đäşĥþőäřđş þy ęmäįľ įʼnčūřş ä čőşŧ pęř äčŧįvę ūşęř. Ğőįʼnģ ƒőřŵäřđ, yőūľľ þę přőmpŧęđ ƒőř päymęʼnŧ ŵĥęʼnęvęř yőū äđđ ʼnęŵ ūşęřş ŧő yőūř đäşĥþőäřđ.",
"bill-ack": "Ĩ ūʼnđęřşŧäʼnđ ŧĥäŧ äđđįʼnģ ūşęřş řęqūįřęş päymęʼnŧ.*",
"cancel-button": "Cäʼnčęľ",
"input-invalid-email-text": "Ĩʼnväľįđ ęmäįľ",
"input-required-email-text": "Ēmäįľ įş řęqūįřęđ",
"invite-button": "Ĩʼnvįŧę",
"invite-field-desc": "Ĩʼnvįŧę pęőpľę þy ęmäįľ",
"invite-field-label": "Ĩʼnvįŧę",
"learn-more-button": "Ŀęäřʼn mőřę",
"recipient-email-placeholder": "Ŧypę įʼn ŧĥę řęčįpįęʼnŧ ęmäįľ äđđřęşş äʼnđ přęşş Ēʼnŧęř",
"recipient-invalid-email-text": "Ĩʼnväľįđ ęmäįľ",
"recipient-invitation-button": "Ĩʼnvįŧę",
"recipient-invitation-description": "Ĩʼnvįŧę şőmęőʼnę þy ęmäįľ",
"recipient-invitation-tooltip": "Ŧĥįş đäşĥþőäřđ čőʼnŧäįʼnş şęʼnşįŧįvę đäŧä. ßy ūşįʼnģ ŧĥįş ƒęäŧūřę yőū ŵįľľ þę şĥäřįʼnģ ŵįŧĥ ęχŧęřʼnäľ pęőpľę.",
"recipient-list-description": "Øʼnľy pęőpľę yőū'vę đįřęčŧľy įʼnvįŧęđ čäʼn äččęşş ŧĥįş đäşĥþőäřđ",
"recipient-list-title": "Pęőpľę ŵįŧĥ äččęşş",
"recipient-required-email-text": "Ēmäįľ įş řęqūįřęđ",
"resend-button": "Ŗęşęʼnđ",
"resend-button-title": "Ŗęşęʼnđ",
"resend-invite-label": "Ŗęşęʼnđ įʼnvįŧę",
"revoke-access-label": "Ŗęvőĸę äččęşş",
"revoke-button": "Ŗęvőĸę",
"revoke-button-title": "Ŗęvőĸę"
"revoke-button-title": "Ŗęvőĸę",
"success-creation": "Ÿőūř đäşĥþőäřđ įş řęäđy ƒőř ęχŧęřʼnäľ şĥäřįʼnģ",
"success-share-type-change": "Đäşĥþőäřđ äččęşş řęşŧřįčŧęđ: Øʼnľy şpęčįƒįč pęőpľę čäʼn ʼnőŵ äččęşş ŵįŧĥ ŧĥę ľįʼnĸ"
},
"modal-alerts": {
"no-upsert-perm-alert-desc": "Cőʼnŧäčŧ yőūř äđmįʼn ŧő ģęŧ pęřmįşşįőʼn ŧő {{mode}} pūþľįč đäşĥþőäřđş",
@@ -1415,6 +1442,15 @@
"unsupported-template-variable-alert-desc": "Ŧĥįş pūþľįč đäşĥþőäřđ mäy ʼnőŧ ŵőřĸ şįʼnčę įŧ ūşęş ŧęmpľäŧę väřįäþľęş",
"unsupported-template-variable-alert-title": "Ŧęmpľäŧę väřįäþľęş äřę ʼnőŧ şūppőřŧęđ"
},
"public-sharing": {
"accept-button": "Åččępŧ",
"alert-text": "Ŝĥäřįʼnģ ŧĥįş đäşĥþőäřđ ęχŧęřʼnäľľy mäĸęş įŧ ęʼnŧįřęľy äččęşşįþľę ŧő äʼnyőʼnę ŵįŧĥ ŧĥę ľįʼnĸ.",
"cancel-button": "Cäʼnčęľ",
"learn-more-button": "Ŀęäřʼn mőřę",
"public-ack": "Ĩ ūʼnđęřşŧäʼnđ ŧĥäŧ ŧĥįş ęʼnŧįřę đäşĥþőäřđ ŵįľľ þę pūþľįč.*",
"success-creation": "Ÿőūř đäşĥþőäřđ įş ʼnőŵ pūþľįčľy äččęşşįþľę",
"success-share-type-change": "Đäşĥþőäřđ äččęşş ūpđäŧęđ: Åʼnyőʼnę ŵįŧĥ ŧĥę ľįʼnĸ čäʼn ʼnőŵ äččęşş"
},
"settings-bar-header": {
"collapse-settings-tooltip": "Cőľľäpşę şęŧŧįʼnģş",
"expand-settings-tooltip": "Ēχpäʼnđ şęŧŧįʼnģş"
@@ -1433,6 +1469,28 @@
"time-range-picker-disabled-text": "Ŧįmę řäʼnģę pįčĸęř = đįşäþľęđ",
"time-range-picker-enabled-text": "Ŧįmę řäʼnģę pįčĸęř = ęʼnäþľęđ",
"time-range-text": "Ŧįmę řäʼnģę = "
},
"share": {
"success-delete": "Ÿőūř đäşĥþőäřđ įş ʼnő ľőʼnģęř şĥäřęäþľę",
"success-delete-old": "Pūþľįč đäşĥþőäřđ đęľęŧęđ!"
},
"share-configuration": {
"access-label": "čäʼn äččęşş",
"share-type-label": "Ŀįʼnĸ äččęşş"
},
"share-externally": {
"copy-link-button": "Cőpy ęχŧęřʼnäľ ľįʼnĸ",
"email-share-type-option-description": "Øʼnľy pęőpľę ŵįŧĥ äččęşş čäʼn őpęʼn ŵįŧĥ ŧĥę ľįʼnĸ",
"email-share-type-option-label": "Øʼnľy şpęčįƒįč pęőpľę",
"pause-access-button": "Päūşę äččęşş",
"pause-access-tooltip": "Päūşįʼnģ ŵįľľ ŧęmpőřäřįľy đįşäþľę äččęşş ŧő ŧĥįş đäşĥþőäřđ ƒőř äľľ ūşęřş",
"public-share-type-option-description": "Åʼnyőʼnę ŵįŧĥ ŧĥę ľįʼnĸ čäʼn äččęşş",
"public-share-type-option-label": "Åʼnyőʼnę ŵįŧĥ ŧĥę ľįʼnĸ",
"resume-access-button": "Ŗęşūmę äččęşş",
"revoke-access-button": "Ŗęvőĸę äččęşş"
},
"sharing": {
"success-creation": "Đäşĥþőäřđ įş pūþľįč!"
}
},
"public-dashboard-list": {