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:
Alexander Zobnin 2022-02-09 13:38:46 +03:00 committed by GitHub
parent ee6f8b6cd9
commit 089a111858
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 212 additions and 7 deletions

View 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;
}
`,
};
};

View File

@ -1,13 +1,20 @@
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { getNavModel } from 'app/core/selectors/navModel';
import Page from 'app/core/components/Page/Page';
import { ServiceAccountProfile } from './ServiceAccountProfile';
import { StoreState, ServiceAccountDTO, ApiKey } from 'app/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 { 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 }> {
navModel: NavModel;
@ -28,6 +35,7 @@ function mapStateToProps(state: StoreState) {
const mapDispatchToProps = {
loadServiceAccount,
loadServiceAccountTokens,
createServiceAccountToken,
deleteServiceAccountToken,
};
@ -43,8 +51,12 @@ const ServiceAccountPageUnconnected = ({
isLoading,
loadServiceAccount,
loadServiceAccountTokens,
createServiceAccountToken,
deleteServiceAccountToken,
}: Props) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [newToken, setNewToken] = useState('');
useEffect(() => {
const serviceAccountId = parseInt(match.params.id, 10);
loadServiceAccount(serviceAccountId);
@ -55,6 +67,22 @@ const ServiceAccountPageUnconnected = ({
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 (
<Page navModel={navModel}>
<Page.Contents isLoading={isLoading}>
@ -77,10 +105,14 @@ const ServiceAccountPageUnconnected = ({
/>
</>
)}
<h3 className="page-heading">Tokens</h3>
{tokens && (
<ServiceAccountTokensTable tokens={tokens} timeZone={timezone} onDelete={onDeleteServiceAccountToken} />
)}
<VerticalGroup spacing="md">
<Button onClick={() => setIsModalOpen(true)}>Add token</Button>
<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>
);

View File

@ -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> {
return async (dispatch) => {
await getBackendSrv().delete(`${BASE_URL}/${saID}/tokens/${id}`);