mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Service accounts: Add token UI (#45081)
* Initial token creation dialog * Use date picker for expiration date * Reset state after closing modal * Create token flow * Move modal to separate component * Minor refactor * Apply suggestions from code review Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com> Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
parent
ee6f8b6cd9
commit
089a111858
161
public/app/features/serviceaccounts/CreateTokenModal.tsx
Normal file
161
public/app/features/serviceaccounts/CreateTokenModal.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ClipboardButton,
|
||||||
|
DatePickerWithInput,
|
||||||
|
Field,
|
||||||
|
FieldSet,
|
||||||
|
HorizontalGroup,
|
||||||
|
Icon,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Modal,
|
||||||
|
RadioButtonGroup,
|
||||||
|
useStyles2,
|
||||||
|
} from '@grafana/ui';
|
||||||
|
|
||||||
|
const EXPIRATION_OPTIONS = [
|
||||||
|
{ label: 'No expiration', value: false },
|
||||||
|
{ label: 'Set expiration date', value: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CreateTokenModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
token: string;
|
||||||
|
onCreateToken: (name: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateTokenModal = ({ isOpen, token, onCreateToken, onClose }: CreateTokenModalProps) => {
|
||||||
|
const [newTokenName, setNewTokenName] = useState('');
|
||||||
|
const [isWithExpirationDate, setIsWithExpirationDate] = useState(false);
|
||||||
|
const [newTokenExpirationDate, setNewTokenExpirationDate] = useState<Date | string>('');
|
||||||
|
const [isExpirationDateValid, setIsExpirationDateValid] = useState(false);
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
const onExpirationDateChange = (value: Date | string) => {
|
||||||
|
const isValid = value !== '';
|
||||||
|
setIsExpirationDateValid(isValid);
|
||||||
|
setNewTokenExpirationDate(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCloseInternal = () => {
|
||||||
|
setNewTokenName('');
|
||||||
|
setIsWithExpirationDate(false);
|
||||||
|
setNewTokenExpirationDate('');
|
||||||
|
setIsExpirationDateValid(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalTitle = (
|
||||||
|
<div className={styles.modalHeaderTitle}>
|
||||||
|
<Icon className={styles.modalHeaderIcon} name="key-skeleton-alt" size="lg" />
|
||||||
|
<span>{!token ? 'Add service account token' : 'Service account token created'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} title={modalTitle} onDismiss={onCloseInternal} className={styles.modal}>
|
||||||
|
{!token ? (
|
||||||
|
<>
|
||||||
|
<FieldSet>
|
||||||
|
<Field
|
||||||
|
label="Display name"
|
||||||
|
description="Optional name to easily identify the token"
|
||||||
|
className={styles.modalRow}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
name="tokenName"
|
||||||
|
value={newTokenName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNewTokenName(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<RadioButtonGroup
|
||||||
|
className={styles.modalRow}
|
||||||
|
options={EXPIRATION_OPTIONS}
|
||||||
|
value={isWithExpirationDate}
|
||||||
|
onChange={setIsWithExpirationDate}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
{isWithExpirationDate && (
|
||||||
|
<Field label="Expiration date" className={styles.modalRow}>
|
||||||
|
<DatePickerWithInput onChange={onExpirationDateChange} value={newTokenExpirationDate} placeholder="" />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</FieldSet>
|
||||||
|
<Button onClick={() => onCreateToken(newTokenName)} disabled={isWithExpirationDate && !isExpirationDateValid}>
|
||||||
|
Generate token
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FieldSet>
|
||||||
|
<Label
|
||||||
|
description="You will not be able to see or generate it again. Loosing a token requires creating new one."
|
||||||
|
className={styles.modalRow}
|
||||||
|
>
|
||||||
|
Copy the token. It will be showed only once.
|
||||||
|
</Label>
|
||||||
|
<Field label="Token" className={styles.modalRow}>
|
||||||
|
<div className={styles.modalTokenRow}>
|
||||||
|
<Input name="tokenValue" value={token} readOnly />
|
||||||
|
<ClipboardButton
|
||||||
|
className={styles.modalCopyToClipboardButton}
|
||||||
|
variant="secondary"
|
||||||
|
size="md"
|
||||||
|
getText={() => token}
|
||||||
|
>
|
||||||
|
<Icon name="copy" /> Copy to clipboard
|
||||||
|
</ClipboardButton>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</FieldSet>
|
||||||
|
<HorizontalGroup>
|
||||||
|
<ClipboardButton variant="primary" getText={() => token} onClipboardCopy={onCloseInternal}>
|
||||||
|
Copy to clipboard and close
|
||||||
|
</ClipboardButton>
|
||||||
|
<Button variant="secondary" onClick={onCloseInternal}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</HorizontalGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
|
return {
|
||||||
|
modal: css`
|
||||||
|
width: 550px;
|
||||||
|
`,
|
||||||
|
modalRow: css`
|
||||||
|
margin-bottom: ${theme.spacing(4)};
|
||||||
|
`,
|
||||||
|
modalTokenRow: css`
|
||||||
|
display: flex;
|
||||||
|
`,
|
||||||
|
modalCopyToClipboardButton: css`
|
||||||
|
margin-left: ${theme.spacing(0.5)};
|
||||||
|
`,
|
||||||
|
modalHeaderTitle: css`
|
||||||
|
font-size: ${theme.typography.size.lg};
|
||||||
|
margin: ${theme.spacing(0, 4, 0, 1)};
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
top: 2px;
|
||||||
|
`,
|
||||||
|
modalHeaderIcon: css`
|
||||||
|
margin-right: ${theme.spacing(2)};
|
||||||
|
font-size: inherit;
|
||||||
|
&:before {
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
};
|
@ -1,13 +1,20 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { connect, ConnectedProps } from 'react-redux';
|
import { connect, ConnectedProps } from 'react-redux';
|
||||||
import { getNavModel } from 'app/core/selectors/navModel';
|
import { getNavModel } from 'app/core/selectors/navModel';
|
||||||
import Page from 'app/core/components/Page/Page';
|
import Page from 'app/core/components/Page/Page';
|
||||||
import { ServiceAccountProfile } from './ServiceAccountProfile';
|
import { ServiceAccountProfile } from './ServiceAccountProfile';
|
||||||
import { StoreState, ServiceAccountDTO, ApiKey } from 'app/types';
|
import { StoreState, ServiceAccountDTO, ApiKey } from 'app/types';
|
||||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||||
import { deleteServiceAccountToken, loadServiceAccount, loadServiceAccountTokens } from './state/actions';
|
import {
|
||||||
|
deleteServiceAccountToken,
|
||||||
|
loadServiceAccount,
|
||||||
|
loadServiceAccountTokens,
|
||||||
|
createServiceAccountToken,
|
||||||
|
} from './state/actions';
|
||||||
import { ServiceAccountTokensTable } from './ServiceAccountTokensTable';
|
import { ServiceAccountTokensTable } from './ServiceAccountTokensTable';
|
||||||
import { getTimeZone, NavModel } from '@grafana/data';
|
import { getTimeZone, NavModel, OrgRole } from '@grafana/data';
|
||||||
|
import { Button, VerticalGroup } from '@grafana/ui';
|
||||||
|
import { CreateTokenModal } from './CreateTokenModal';
|
||||||
|
|
||||||
interface OwnProps extends GrafanaRouteComponentProps<{ id: string }> {
|
interface OwnProps extends GrafanaRouteComponentProps<{ id: string }> {
|
||||||
navModel: NavModel;
|
navModel: NavModel;
|
||||||
@ -28,6 +35,7 @@ function mapStateToProps(state: StoreState) {
|
|||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
loadServiceAccount,
|
loadServiceAccount,
|
||||||
loadServiceAccountTokens,
|
loadServiceAccountTokens,
|
||||||
|
createServiceAccountToken,
|
||||||
deleteServiceAccountToken,
|
deleteServiceAccountToken,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -43,8 +51,12 @@ const ServiceAccountPageUnconnected = ({
|
|||||||
isLoading,
|
isLoading,
|
||||||
loadServiceAccount,
|
loadServiceAccount,
|
||||||
loadServiceAccountTokens,
|
loadServiceAccountTokens,
|
||||||
|
createServiceAccountToken,
|
||||||
deleteServiceAccountToken,
|
deleteServiceAccountToken,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [newToken, setNewToken] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const serviceAccountId = parseInt(match.params.id, 10);
|
const serviceAccountId = parseInt(match.params.id, 10);
|
||||||
loadServiceAccount(serviceAccountId);
|
loadServiceAccount(serviceAccountId);
|
||||||
@ -55,6 +67,22 @@ const ServiceAccountPageUnconnected = ({
|
|||||||
deleteServiceAccountToken(parseInt(match.params.id, 10), key.id!);
|
deleteServiceAccountToken(parseInt(match.params.id, 10), key.id!);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onCreateToken = (name: string) => {
|
||||||
|
createServiceAccountToken(
|
||||||
|
serviceAccount.id,
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
role: OrgRole.Viewer,
|
||||||
|
},
|
||||||
|
setNewToken
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onModalClose = () => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setNewToken('');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page navModel={navModel}>
|
<Page navModel={navModel}>
|
||||||
<Page.Contents isLoading={isLoading}>
|
<Page.Contents isLoading={isLoading}>
|
||||||
@ -77,10 +105,14 @@ const ServiceAccountPageUnconnected = ({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<h3 className="page-heading">Tokens</h3>
|
<VerticalGroup spacing="md">
|
||||||
{tokens && (
|
<Button onClick={() => setIsModalOpen(true)}>Add token</Button>
|
||||||
<ServiceAccountTokensTable tokens={tokens} timeZone={timezone} onDelete={onDeleteServiceAccountToken} />
|
<h3 className="page-heading">Tokens</h3>
|
||||||
)}
|
{tokens && (
|
||||||
|
<ServiceAccountTokensTable tokens={tokens} timeZone={timezone} onDelete={onDeleteServiceAccountToken} />
|
||||||
|
)}
|
||||||
|
</VerticalGroup>
|
||||||
|
<CreateTokenModal isOpen={isModalOpen} token={newToken} onCreateToken={onCreateToken} onClose={onModalClose} />
|
||||||
</Page.Contents>
|
</Page.Contents>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
@ -15,6 +15,18 @@ export function loadServiceAccount(saID: number): ThunkResult<void> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createServiceAccountToken(
|
||||||
|
saID: number,
|
||||||
|
data: any,
|
||||||
|
onTokenCreated: (key: string) => void
|
||||||
|
): ThunkResult<void> {
|
||||||
|
return async (dispatch) => {
|
||||||
|
const result = await getBackendSrv().post(`${BASE_URL}/${saID}/tokens`, data);
|
||||||
|
onTokenCreated(result.key);
|
||||||
|
dispatch(loadServiceAccountTokens(saID));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteServiceAccountToken(saID: number, id: number): ThunkResult<void> {
|
export function deleteServiceAccountToken(saID: number, id: number): ThunkResult<void> {
|
||||||
return async (dispatch) => {
|
return async (dispatch) => {
|
||||||
await getBackendSrv().delete(`${BASE_URL}/${saID}/tokens/${id}`);
|
await getBackendSrv().delete(`${BASE_URL}/${saID}/tokens/${id}`);
|
||||||
|
Loading…
Reference in New Issue
Block a user