diff --git a/pkg/api/api.go b/pkg/api/api.go index 78a8c08e283..d097e5baabc 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -59,6 +59,8 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/org/users/invite", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionUsersCreate)), hs.Index) r.Get("/org/teams", reqCanAccessTeams, hs.Index) r.Get("/org/teams/*", reqCanAccessTeams, hs.Index) + r.Get("/org/serviceaccounts", middleware.ReqOrgAdmin, hs.Index) + r.Get("/org/serviceaccounts/:serviceAccountId", middleware.ReqOrgAdmin, hs.Index) r.Get("/org/apikeys/", reqOrgAdmin, hs.Index) r.Get("/dashboard/import/", reqSignedIn, hs.Index) r.Get("/configuration", reqGrafanaAdmin, hs.Index) diff --git a/pkg/services/serviceaccounts/api/api.go b/pkg/services/serviceaccounts/api/api.go index 0a5510e89df..60a1a91f6f1 100644 --- a/pkg/services/serviceaccounts/api/api.go +++ b/pkg/services/serviceaccounts/api/api.go @@ -45,6 +45,7 @@ func (api *ServiceAccountsAPI) RegisterAPIEndpoints( if !cfg.FeatureToggles["service-accounts"] { return } + auth := acmiddleware.Middleware(api.accesscontrol) api.RouterRegister.Group("/api/org/serviceaccounts", func(serviceAccountsRoute routing.RouteRegister) { serviceAccountsRoute.Get("/", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeAll)), routing.Wrap(api.ListServiceAccounts)) diff --git a/public/app/features/serviceaccounts/ServiceAccountPage.tsx b/public/app/features/serviceaccounts/ServiceAccountPage.tsx new file mode 100644 index 00000000000..2328301a965 --- /dev/null +++ b/public/app/features/serviceaccounts/ServiceAccountPage.tsx @@ -0,0 +1,67 @@ +import React, { PureComponent } from 'react'; +import { connect, ConnectedProps } from 'react-redux'; +import { NavModel } from '@grafana/data'; +import { getNavModel } from 'app/core/selectors/navModel'; +import Page from 'app/core/components/Page/Page'; +import { ServiceAccountProfile } from './ServiceAccountProfile'; +import { StoreState, ServiceAccountDTO } from 'app/types'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { loadServiceAccount } from './state/actions'; + +interface OwnProps extends GrafanaRouteComponentProps<{ id: string }> { + navModel: NavModel; + serviceAccount?: ServiceAccountDTO; + isLoading: boolean; +} + +export class ServiceAccountPage extends PureComponent { + async componentDidMount() { + const { match } = this.props; + this.props.loadServiceAccount(parseInt(match.params.id, 10)); + } + + render() { + const { navModel, serviceAccount, isLoading } = this.props; + + return ( + + + {serviceAccount && ( + <> + { + console.log(`not implemented`); + }} + onServiceAccountUpdate={() => { + console.log(`not implemented`); + }} + onServiceAccountDisable={() => { + console.log(`not implemented`); + }} + onServiceAccountEnable={() => { + console.log(`not implemented`); + }} + /> + + )} + + + ); + } +} + +function mapStateToProps(state: StoreState) { + return { + navModel: getNavModel(state.navIndex, 'serviceaccounts'), + serviceAccount: state.serviceAccountProfile.serviceAccount, + isLoading: state.serviceAccountProfile.isLoading, + }; +} +const mapDispatchToProps = { + loadServiceAccount, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); +type Props = OwnProps & ConnectedProps; +export default connector(ServiceAccountPage); diff --git a/public/app/features/serviceaccounts/ServiceAccountProfile.tsx b/public/app/features/serviceaccounts/ServiceAccountProfile.tsx new file mode 100644 index 00000000000..b7e10fbe345 --- /dev/null +++ b/public/app/features/serviceaccounts/ServiceAccountProfile.tsx @@ -0,0 +1,237 @@ +import React, { PureComponent, useRef, useState } from 'react'; +import { ServiceAccountDTO } from 'app/types'; +import { css, cx } from '@emotion/css'; +import { config } from 'app/core/config'; +import { GrafanaTheme } from '@grafana/data'; +import { Button, ConfirmButton, ConfirmModal, Input, LegacyInputStatus, stylesFactory } from '@grafana/ui'; + +interface Props { + serviceaccount: ServiceAccountDTO; + + onServiceAccountUpdate: (serviceaccount: ServiceAccountDTO) => void; + onServiceAccountDelete: (serviceaccountId: number) => void; + onServiceAccountDisable: (serviceaccountId: number) => void; + onServiceAccountEnable: (serviceaccountId: number) => void; +} + +export function ServiceAccountProfile({ + serviceaccount, + onServiceAccountUpdate, + onServiceAccountDelete, + onServiceAccountDisable, + onServiceAccountEnable, +}: Props) { + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showDisableModal, setShowDisableModal] = useState(false); + + const deleteServiceAccountRef = useRef(null); + const showDeleteServiceAccountModal = (show: boolean) => () => { + setShowDeleteModal(show); + if (!show && deleteServiceAccountRef.current) { + deleteServiceAccountRef.current.focus(); + } + }; + + const disableServiceAccountRef = useRef(null); + const showDisableServiceAccountModal = (show: boolean) => () => { + setShowDisableModal(show); + if (!show && disableServiceAccountRef.current) { + disableServiceAccountRef.current.focus(); + } + }; + + const handleServiceAccountDelete = () => onServiceAccountDelete(serviceaccount.userId); + + const handleServiceAccountDisable = () => onServiceAccountDisable(serviceaccount.userId); + + const handleServiceAccountEnable = () => onServiceAccountEnable(serviceaccount.userId); + + const onServiceAccountNameChange = (newValue: string) => { + onServiceAccountUpdate({ + ...serviceaccount, + name: newValue, + }); + }; + + const styles = getStyles(config.theme); + + return ( + <> +

Service account information

+
+
+ + + + + + + + + +
+
+
+ <> + + + + + <> + + + +
+
+ + ); +} + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + return { + buttonRow: css` + margin-top: 0.8rem; + > * { + margin-right: 16px; + } + `, + }; +}); + +interface ServiceAccountProfileRowProps { + label: string; + value?: string; + inputType?: string; + onChange?: (value: string) => void; +} + +interface ServiceAccountProfileRowState { + value: string; + editing: boolean; +} + +export class ServiceAccountProfileRow extends PureComponent< + ServiceAccountProfileRowProps, + ServiceAccountProfileRowState +> { + inputElem?: HTMLInputElement; + + static defaultProps: Partial = { + value: '', + inputType: 'text', + }; + + state = { + editing: false, + value: this.props.value || '', + }; + + setInputElem = (elem: any) => { + this.inputElem = elem; + }; + + onEditClick = () => { + this.setState({ editing: true }, this.focusInput); + }; + + onCancelClick = () => { + this.setState({ editing: false, value: this.props.value || '' }); + }; + + onInputChange = (event: React.ChangeEvent, status?: LegacyInputStatus) => { + if (status === LegacyInputStatus.Invalid) { + return; + } + + this.setState({ value: event.target.value }); + }; + + onInputBlur = (event: React.FocusEvent, status?: LegacyInputStatus) => { + if (status === LegacyInputStatus.Invalid) { + return; + } + + this.setState({ value: event.target.value }); + }; + + focusInput = () => { + if (this.inputElem && this.inputElem.focus) { + this.inputElem.focus(); + } + }; + + onSave = () => { + if (this.props.onChange) { + this.props.onChange(this.state.value); + } + }; + + render() { + const { label, inputType } = this.props; + const { value } = this.state; + const labelClass = cx( + 'width-16', + css` + font-weight: 500; + ` + ); + + const inputId = `${label}-input`; + return ( + + + + + + {this.state.editing ? ( + + ) : ( + {this.props.value} + )} + + + + Edit + + + + ); + } +} diff --git a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx index 8e91a0e5b8f..cb73a7bc920 100644 --- a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx @@ -1,85 +1,26 @@ -import React, { PureComponent } from 'react'; +import React, { memo, useEffect } from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import { HorizontalGroup, Pagination, VerticalGroup } from '@grafana/ui'; +import { useStyles2 } from '@grafana/ui'; +import { css, cx } from '@emotion/css'; import Page from 'app/core/components/Page/Page'; -import ServiceAccountsTable from './ServiceAccountsTable'; -import { OrgServiceAccount, OrgRole, StoreState } from 'app/types'; +import { StoreState, ServiceAccountDTO } from 'app/types'; import { loadServiceAccounts, removeServiceAccount, updateServiceAccount } from './state/actions'; import { getNavModel } from 'app/core/selectors/navModel'; import { getServiceAccounts, getServiceAccountsSearchPage, getServiceAccountsSearchQuery } from './state/selectors'; -import { setServiceAccountsSearchPage } from './state/reducers'; +import PageLoader from 'app/core/components/PageLoader/PageLoader'; +import { GrafanaTheme2 } from '@grafana/data'; export type Props = ConnectedProps; export interface State {} -const ITEMS_PER_PAGE = 30; - -export class ServiceAccountsListPage extends PureComponent { - componentDidMount() { - this.fetchServiceAccounts(); - } - - async fetchServiceAccounts() { - return this.props.loadServiceAccounts(); - } - - onRoleChange = (role: OrgRole, serviceAccount: OrgServiceAccount) => { - const updatedServiceAccount = { ...serviceAccount, role: role }; - - this.props.updateServiceAccount(updatedServiceAccount); - }; - - getPaginatedServiceAccounts = (serviceAccounts: OrgServiceAccount[]) => { - const offset = (this.props.searchPage - 1) * ITEMS_PER_PAGE; - return serviceAccounts.slice(offset, offset + ITEMS_PER_PAGE); - }; - - renderTable() { - const { serviceAccounts } = this.props; - const paginatedServiceAccounts = this.getPaginatedServiceAccounts(serviceAccounts); - const totalPages = Math.ceil(serviceAccounts.length / ITEMS_PER_PAGE); - - return ( - -

Service Accounts

- this.onRoleChange(role, serviceAccount)} - onRemoveServiceAccount={(serviceAccount) => this.props.removeServiceAccount(serviceAccount.serviceAccountId)} - /> - - - -
- ); - } - - render() { - const { navModel, hasFetched } = this.props; - - return ( - - - <>{hasFetched && this.renderTable()} - - - ); - } -} - function mapStateToProps(state: StoreState) { return { navModel: getNavModel(state.navIndex, 'serviceaccounts'), serviceAccounts: getServiceAccounts(state.serviceAccounts), searchQuery: getServiceAccountsSearchQuery(state.serviceAccounts), searchPage: getServiceAccountsSearchPage(state.serviceAccounts), - hasFetched: state.serviceAccounts.isLoading, + isLoading: state.serviceAccounts.isLoading, }; } @@ -91,4 +32,156 @@ const mapDispatchToProps = { const connector = connect(mapStateToProps, mapDispatchToProps); -export default connector(ServiceAccountsListPage); +const ServiceAccountsListPage2: React.FC = ({ loadServiceAccounts, navModel, serviceAccounts, isLoading }) => { + const styles = useStyles2(getStyles); + + useEffect(() => { + loadServiceAccounts(); + }, [loadServiceAccounts]); + return ( + + + {isLoading ? ( + + ) : ( + <> +
+ + + + + + + + + + + + {serviceAccounts.map((serviceaccount: ServiceAccountDTO) => ( + + ))} + +
AccountIDRolesTokens
+
+ + )} +
+
+ ); +}; + +type ServiceAccountListItemProps = { + serviceaccount: ServiceAccountDTO; +}; + +const getServiceAccountsAriaLabel = (name: string) => { + return `Edit service account's ${name} details`; +}; + +const ServiceAccountListItem = memo(({ serviceaccount }: ServiceAccountListItemProps) => { + const editUrl = `org/serviceaccounts/${serviceaccount.userId}`; + const styles = useStyles2(getStyles); + + return ( + + + + {`Avatar + + + + + {serviceaccount.login} + + + + + {serviceaccount.name} + + + + + {serviceaccount.role === 'None' ? ( + Not assigned + ) : ( + serviceaccount.role + )} + + + + + 0 + + + + ); +}); +ServiceAccountListItem.displayName = 'ServiceAccountListItem'; + +const getStyles = (theme: GrafanaTheme2) => { + return { + table: css` + margin-top: ${theme.spacing(3)}; + `, + filter: css` + margin: 0 ${theme.spacing(1)}; + `, + iconRow: css` + svg { + margin-left: ${theme.spacing(0.5)}; + } + `, + row: css` + display: flex; + align-items: center; + height: 100% !important; + + a { + padding: ${theme.spacing(0.5)} 0 !important; + } + `, + unitTooltip: css` + display: flex; + flex-direction: column; + `, + unitItem: css` + cursor: pointer; + padding: ${theme.spacing(0.5)} 0; + margin-right: ${theme.spacing(1)}; + `, + disabled: css` + color: ${theme.colors.text.disabled}; + `, + link: css` + color: inherit; + cursor: pointer; + text-decoration: underline; + `, + }; +}; + +export default connector(ServiceAccountsListPage2); diff --git a/public/app/features/serviceaccounts/state/actions.ts b/public/app/features/serviceaccounts/state/actions.ts index a4dc6243aa6..7066eb750f3 100644 --- a/public/app/features/serviceaccounts/state/actions.ts +++ b/public/app/features/serviceaccounts/state/actions.ts @@ -1,23 +1,36 @@ import { ThunkResult } from '../../../types'; import { getBackendSrv } from '@grafana/runtime'; -import { OrgServiceAccount as OrgServiceAccount } from 'app/types'; -import { serviceAccountsLoaded } from './reducers'; +import { ServiceAccountDTO } from 'app/types'; +import { serviceAccountLoaded, serviceAccountsLoaded } from './reducers'; const BASE_URL = `/api/org/serviceaccounts`; -export function loadServiceAccounts(): ThunkResult { +export function loadServiceAccount(id: number): ThunkResult { return async (dispatch) => { - const serviceAccounts = await getBackendSrv().get(BASE_URL); - dispatch(serviceAccountsLoaded(serviceAccounts)); + try { + const response = await getBackendSrv().get(`${BASE_URL}/${id}`); + dispatch(serviceAccountLoaded(response)); + } catch (error) { + console.error(error); + } }; } -export function updateServiceAccount(serviceAccount: OrgServiceAccount): ThunkResult { +export function loadServiceAccounts(): ThunkResult { + return async (dispatch) => { + try { + const response = await getBackendSrv().get(BASE_URL); + dispatch(serviceAccountsLoaded(response)); + } catch (error) { + console.error(error); + } + }; +} + +export function updateServiceAccount(serviceAccount: ServiceAccountDTO): ThunkResult { return async (dispatch) => { // TODO: implement on backend - await getBackendSrv().patch(`${BASE_URL}/${serviceAccount.serviceAccountId}`, { - role: serviceAccount.role, - }); + await getBackendSrv().patch(`${BASE_URL}/${serviceAccount.userId}`, {}); dispatch(loadServiceAccounts()); }; } diff --git a/public/app/features/serviceaccounts/state/reducers.ts b/public/app/features/serviceaccounts/state/reducers.ts index 9243e07f88b..4ccdcd4af7c 100644 --- a/public/app/features/serviceaccounts/state/reducers.ts +++ b/public/app/features/serviceaccounts/state/reducers.ts @@ -1,20 +1,35 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { OrgServiceAccount, ServiceAccountsState } from 'app/types'; +import { ServiceAccountDTO, ServiceAccountProfileState, ServiceAccountsState } from 'app/types'; export const initialState: ServiceAccountsState = { - serviceAccounts: [] as OrgServiceAccount[], + serviceAccounts: [] as ServiceAccountDTO[], searchQuery: '', searchPage: 1, isLoading: true, }; +export const initialStateProfile: ServiceAccountProfileState = { + serviceAccount: {} as ServiceAccountDTO, + isLoading: true, +}; + +export const serviceAccountProfileSlice = createSlice({ + name: 'serviceaccount', + initialState: initialStateProfile, + reducers: { + serviceAccountLoaded: (state, action: PayloadAction): ServiceAccountProfileState => { + return { ...state, serviceAccount: action.payload, isLoading: false }; + }, + }, +}); + const serviceAccountsSlice = createSlice({ name: 'serviceaccounts', initialState, reducers: { - serviceAccountsLoaded: (state, action: PayloadAction): ServiceAccountsState => { - return { ...state, isLoading: true, serviceAccounts: action.payload }; + serviceAccountsLoaded: (state, action: PayloadAction): ServiceAccountsState => { + return { ...state, isLoading: false, serviceAccounts: action.payload }; }, setServiceAccountsSearchQuery: (state, action: PayloadAction): ServiceAccountsState => { // reset searchPage otherwise search results won't appear @@ -32,8 +47,12 @@ export const { serviceAccountsLoaded, } = serviceAccountsSlice.actions; +export const { serviceAccountLoaded } = serviceAccountProfileSlice.actions; + +export const serviceAccountProfileReducer = serviceAccountProfileSlice.reducer; export const serviceAccountsReducer = serviceAccountsSlice.reducer; export default { + serviceAccountProfile: serviceAccountProfileReducer, serviceAccounts: serviceAccountsReducer, }; diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 54870b3872d..b43f6cb8f17 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -12,6 +12,7 @@ import { getRoutes as getPluginCatalogRoutes } from 'app/features/plugins/admin/ import { contextSrv } from 'app/core/services/context_srv'; import { getLiveRoutes } from 'app/features/live/pages/routes'; import { getAlertingRoutes } from 'app/features/alerting/routes'; +import ServiceAccountPage from 'app/features/serviceaccounts/ServiceAccountPage'; export const extraRoutes: RouteDescriptor[] = []; @@ -190,6 +191,10 @@ export function getAppRoutes(): RouteDescriptor[] { import(/* webpackChunkName: "ServiceAccountsPage" */ 'app/features/serviceaccounts/ServiceAccountsListPage') ), }, + { + path: '/org/serviceaccounts/:id', + component: ServiceAccountPage, + }, { path: '/org/teams', roles: () => (config.editorsCanAdmin ? [] : ['Editor', 'Admin']), diff --git a/public/app/types/serviceaccount.ts b/public/app/types/serviceaccount.ts index 7b16ab24231..715c46b3496 100644 --- a/public/app/types/serviceaccount.ts +++ b/public/app/types/serviceaccount.ts @@ -1,4 +1,5 @@ -import { OrgRole, Unit } from '.'; +import { WithAccessControlMetadata } from '@grafana/data'; +import { OrgRole } from '.'; export interface OrgServiceAccount { serviceAccountId: number; @@ -23,26 +24,25 @@ export interface ServiceAccount { orgId?: number; } -export interface ServiceAccountDTO { - id: number; - login: string; +export interface ServiceAccountDTO extends WithAccessControlMetadata { + orgId: number; + userId: number; email: string; name: string; - isGrafanaAdmin: boolean; - isDisabled: boolean; - isAdmin?: boolean; - updatedAt?: string; - authLabels?: string[]; avatarUrl?: string; - orgId?: number; - licensedRole?: string; - permissions?: string[]; - teams?: Unit[]; - orgs?: Unit[]; + login: string; + role: string; + lastSeenAt: string; + lastSeenAtAge: string; +} + +export interface ServiceAccountProfileState { + serviceAccount: ServiceAccountDTO; + isLoading: boolean; } export interface ServiceAccountsState { - serviceAccounts: OrgServiceAccount[]; + serviceAccounts: ServiceAccountDTO[]; searchQuery: string; searchPage: number; isLoading: boolean;