zuul/web/src/App.jsx

536 lines
16 KiB
JavaScript

// Copyright 2018 Red Hat, Inc
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
// The App is the parent component of every pages. Each page content is
// rendered by the Route object according to the current location.
import React from 'react'
import PropTypes from 'prop-types'
import { matchPath, withRouter } from 'react-router'
import { Link, NavLink, Redirect, Route, Switch } from 'react-router-dom'
import { connect } from 'react-redux'
import { withAuth } from 'oidc-react'
import {
TimedToastNotification,
ToastNotificationList,
} from 'patternfly-react'
import * as moment from 'moment'
import {
Brand,
Button,
ButtonVariant,
Dropdown,
DropdownItem,
DropdownToggle,
DropdownSeparator,
KebabToggle,
Nav,
NavItem,
NavList,
NotificationBadge,
Page,
PageHeader,
PageHeaderTools,
PageHeaderToolsGroup,
PageHeaderToolsItem,
} from '@patternfly/react-core'
import {
BellIcon,
BookIcon,
ChevronDownIcon,
CodeIcon,
ServiceIcon,
UsersIcon,
} from '@patternfly/react-icons'
import AuthContainer from './containers/auth/Auth'
import ErrorBoundary from './containers/ErrorBoundary'
import { Fetching } from './containers/Fetching'
import SelectTz from './containers/timezone/SelectTz'
import ConfigModal from './containers/config/Config'
import logo from './images/logo.svg'
import { clearNotification } from './actions/notifications'
import { fetchConfigErrorsAction, clearConfigErrorsAction } from './actions/configErrors'
import { fetchTenantsIfNeeded } from './actions/tenants'
import { routes } from './routes'
import { setTenantAction } from './actions/tenant'
import { configureAuthFromTenant, configureAuthFromInfo } from './actions/auth'
import { getHomepageUrl } from './api'
import AuthCallbackPage from './pages/AuthCallback'
import AuthRequiredPage from './pages/AuthRequired'
class App extends React.Component {
static propTypes = {
notifications: PropTypes.array,
configErrors: PropTypes.array,
configErrorsReady: PropTypes.bool,
info: PropTypes.object,
tenant: PropTypes.object,
tenants: PropTypes.object,
timezone: PropTypes.string,
location: PropTypes.object,
history: PropTypes.object,
dispatch: PropTypes.func,
isKebabDropdownOpen: PropTypes.bool,
user: PropTypes.object,
auth: PropTypes.object,
signIn: PropTypes.func,
}
state = {
isTenantDropdownOpen: false,
}
renderMenu() {
const { tenant } = this.props
if (tenant.name) {
return (
<Nav aria-label="Nav" variant="horizontal">
<NavList>
{this.menu.filter(item => item.title).map(item => (
<NavItem itemId={item.to} key={item.to}>
<NavLink
to={tenant.linkPrefix + item.to}
activeClassName="pf-c-nav__link pf-m-current"
>
{item.title}
</NavLink>
</NavItem>
))}
</NavList>
</Nav>
)
} else {
// Return an empty navigation bar in case we don't have an active tenant
return <Nav aria-label="Nav" variant="horizontal" />
}
}
isAuthReady() {
const { info, auth, user } = this.props
return !(info.isFetching ||
!auth.info ||
auth.isFetching ||
(user.data && user.data.isFetching) ||
user.isFetching)
}
renderContent = () => {
const { tenant, auth, user } = this.props
const allRoutes = []
if ((window.location.origin + window.location.pathname) ===
(getHomepageUrl() + 'auth_callback')) {
// Sit on the auth callback page until login and token
// validation is complete (it will internally redirect when complete)
return <AuthCallbackPage/>
}
if (!this.isAuthReady()) {
return <Fetching />
}
if (auth.info.read_protected && !user.data) {
console.log('Read-access login required')
const redirect_target = window.location.href.slice(getHomepageUrl().length)
localStorage.setItem('zuul_auth_redirect', redirect_target)
this.props.signIn()
return <Fetching />
}
if (auth.info.read_protected && user.scope.length<1) {
return <AuthRequiredPage/>
}
this.menu
// Do not include '/tenants' route in white-label setup
.filter(item =>
(tenant.whiteLabel && !item.globalRoute) || !tenant.whiteLabel)
.forEach((item, index) => {
// We use react-router's render function to be able to pass custom props
// to our route components (pages):
// https://reactrouter.com/web/api/Route/render-func
// https://learnwithparam.com/blog/how-to-pass-props-in-react-router/
allRoutes.push(
<Route
key={index}
path={
item.globalRoute ? item.to :
item.noTenantPrefix ? item.to : tenant.routePrefix + item.to}
render={routerProps => (
<item.component {...item.props} {...routerProps} />
)}
exact
/>
)
})
if (tenant.defaultRoute)
allRoutes.push(
<Redirect from='*' to={tenant.defaultRoute} key='default-route' />
)
return (
<Switch>
{allRoutes}
</Switch>
)
}
componentDidUpdate() {
// This method is called when info property is updated
const { tenant, info, auth, user, configErrorsReady } = this.props
if (info.ready) {
let tenantName = null
let whiteLabel
if (info.tenant) {
// White label
whiteLabel = true
tenantName = info.tenant
} else if (!info.tenant) {
// Multi tenant, look for tenant name in url
whiteLabel = false
this.props.dispatch(fetchTenantsIfNeeded())
const match = matchPath(
this.props.location.pathname, { path: '/t/:tenant' })
if (match) {
tenantName = match.params.tenant
}
}
// Set tenant only if it changed to prevent DidUpdate loop
if (tenant.name !== tenantName) {
const tenantAction = setTenantAction(tenantName, whiteLabel)
this.props.dispatch(tenantAction)
if (tenantName) {
this.props.dispatch(clearConfigErrorsAction())
}
if (whiteLabel || !tenantName) {
// The app info endpoint was already a tenant info
// endpoint, so auth info was already provided.
this.props.dispatch(configureAuthFromInfo(info))
} else {
// Query the tenant info endpoint for auth info.
this.props.dispatch(configureAuthFromTenant(tenantName))
}
}
if (tenant && tenant.name && !configErrorsReady && this.isAuthReady() &&
(!auth.info.read_protected || user.data)) {
// This will happen after the tenant action is complete, so we
// can use the "old" tenant now.
this.props.dispatch(fetchConfigErrorsAction(tenant))
}
}
}
constructor() {
super()
this.menu = routes()
}
handleKebabDropdownToggle = (isKebabDropdownOpen) => {
this.setState({
isKebabDropdownOpen
})
}
handleKebabDropdownSelect = () => {
this.setState({
isKebabDropdownOpen: !this.state.isKebabDropdownOpen
})
}
handleComponentsLink = () => {
const { history } = this.props
history.push('/components')
}
handleApiLink = () => {
const { history } = this.props
history.push('/openapi')
}
handleDocumentationLink = () => {
window.open('https://zuul-ci.org/docs', '_blank', 'noopener noreferrer')
}
handleTenantLink = () => {
const { history, tenant } = this.props
history.push(tenant.defaultRoute)
}
renderNotifications = (notifications) => {
return (
<ToastNotificationList>
{notifications.map(notification => {
let notificationBody
if (notification.type === 'error') {
notificationBody = (
<>
<strong>{notification.text}</strong> {notification.status} &nbsp;
{notification.url}
</>
)
} else {
notificationBody = (<span>{notification.text}</span>)
}
return (
<TimedToastNotification
key={notification.id}
type={notification.type}
onDismiss={() => { this.props.dispatch(clearNotification(notification.id)) }}
>
<span title={moment.utc(notification.date).tz(this.props.timezone).format()}>
{notificationBody}
</span>
</TimedToastNotification>
)
}
)}
</ToastNotificationList>
)
}
renderTenantDropdown() {
const { tenant, tenants } = this.props
const { isTenantDropdownOpen } = this.state
if (tenant.whiteLabel) {
return (
<PageHeaderToolsItem>
<strong>Tenant</strong> {tenant.name}
</PageHeaderToolsItem>
)
} else {
const tenantLink = (_tenant) => {
const currentPath = this.props.location.pathname
let suffix
switch (currentPath) {
case '/t/' + tenant.name + '/projects':
suffix = '/projects'
break
case '/t/' + tenant.name + '/jobs':
suffix = '/jobs'
break
case '/t/' + tenant.name + '/labels':
suffix = '/labels'
break
case '/t/' + tenant.name + '/nodes':
suffix = '/nodes'
break
case '/t/' + tenant.name + '/autoholds':
suffix = '/autoholds'
break
case '/t/' + tenant.name + '/builds':
suffix = '/builds'
break
case '/t/' + tenant.name + '/buildsets':
suffix = '/buildsets'
break
case '/t/' + tenant.name + '/status':
default:
// all other paths point to tenant-specific resources that would most likely result in a 404
suffix = '/status'
break
}
return <Link to={'/t/' + _tenant.name + suffix}>{_tenant.name}</Link>
}
const options = tenants.tenants.filter(
(_tenant) => (_tenant.name !== tenant.name)
).map(
(_tenant, idx) => {
return (
<DropdownItem key={'tenant-dropdown-' + idx} component={tenantLink(_tenant)} />
)
})
options.push(
<DropdownSeparator key="tenant-dropdown-separator" />,
<DropdownItem
key="tenant-dropdown-tenants_page"
component={<Link to={tenant.defaultRoute}>Go to tenants page</Link>} />
)
return (tenants.isFetching ?
<PageHeaderToolsItem>
Loading tenants ...
</PageHeaderToolsItem> :
<>
<PageHeaderToolsItem>
<Dropdown
isOpen={isTenantDropdownOpen}
toggle={
<DropdownToggle
className={`zuul-menu-dropdown-toggle${isTenantDropdownOpen ? '-expanded' : ''}`}
id="tenant-dropdown-toggle-id"
onToggle={(isOpen) => { this.setState({ isTenantDropdownOpen: isOpen }) }}
toggleIndicator={ChevronDownIcon}
>
<strong>Tenant</strong> {tenant.name}
</DropdownToggle>}
onSelect={() => { this.setState({ isTenantDropdownOpen: !isTenantDropdownOpen }) }}
dropdownItems={options}
/>
</PageHeaderToolsItem>
</>)
}
}
render() {
const { isKebabDropdownOpen } = this.state
const { notifications, configErrors, tenant, info, auth } = this.props
const nav = this.renderMenu()
const kebabDropdownItems = []
if (!info.tenant) {
kebabDropdownItems.push(
<DropdownItem
key="components"
onClick={event => this.handleComponentsLink(event)}
>
<ServiceIcon /> Components
</DropdownItem>
)
}
kebabDropdownItems.push(
<DropdownItem key="api" onClick={event => this.handleApiLink(event)}>
<CodeIcon /> API
</DropdownItem>
)
kebabDropdownItems.push(
<DropdownItem
key="documentation"
onClick={event => this.handleDocumentationLink(event)}
>
<BookIcon /> Documentation
</DropdownItem>
)
if (tenant.name) {
kebabDropdownItems.push(
<DropdownItem
key="tenant"
onClick={event => this.handleTenantLink(event)}
>
<UsersIcon /> Tenants
</DropdownItem>
)
}
const pageHeaderTools = (
<PageHeaderTools>
{/* The utility navbar is only visible on desktop sizes
and replaced by a kebab dropdown for smaller sizes */}
<PageHeaderToolsGroup
visibility={{ default: 'hidden', lg: 'visible' }}
>
{ (!info.tenant) &&
<PageHeaderToolsItem>
<Link to='/components'>
<Button variant={ButtonVariant.plain}>
<ServiceIcon /> Components
</Button>
</Link>
</PageHeaderToolsItem>
}
<PageHeaderToolsItem>
<Link to='/openapi'>
<Button variant={ButtonVariant.plain}>
<CodeIcon /> API
</Button>
</Link>
</PageHeaderToolsItem>
<PageHeaderToolsItem>
<a
href='https://zuul-ci.org/docs'
rel='noopener noreferrer'
target='_blank'
>
<Button variant={ButtonVariant.plain}>
<BookIcon /> Documentation
</Button>
</a>
</PageHeaderToolsItem>
{tenant.name && (this.renderTenantDropdown())}
</PageHeaderToolsGroup>
<PageHeaderToolsGroup>
{/* this kebab dropdown replaces the icon buttons and is hidden for
desktop sizes */}
<PageHeaderToolsItem visibility={{ lg: 'hidden' }}>
<Dropdown
isPlain
position="right"
onSelect={this.handleKebabDropdownSelect}
toggle={<KebabToggle onToggle={this.handleKebabDropdownToggle} />}
isOpen={isKebabDropdownOpen}
dropdownItems={kebabDropdownItems}
/>
</PageHeaderToolsItem>
</PageHeaderToolsGroup>
{configErrors.length > 0 &&
<NotificationBadge
isRead={false}
aria-label="Notifications"
>
<Link to={this.props.tenant.linkPrefix + '/config-errors'}
style={{color: 'white'}}>
<BellIcon />
</Link>
</NotificationBadge>
}
<SelectTz />
<ConfigModal />
{auth.info && auth.info.default_realm && (<AuthContainer />)}
</PageHeaderTools>
)
// In case we don't have an active tenant, fall back to the root URL
const logoUrl = tenant.name ? tenant.defaultRoute : '/'
const pageHeader = (
<PageHeader
logo={<Brand src={logo} alt='Zuul logo' />}
logoProps={{ to: logoUrl }}
logoComponent={Link}
headerTools={pageHeaderTools}
/>
)
return (
<React.Fragment>
{notifications.length > 0 && this.renderNotifications(notifications)}
<Page className="zuul-page" header={pageHeader} tertiaryNav={nav}>
<ErrorBoundary>
{this.renderContent()}
</ErrorBoundary>
</Page>
</React.Fragment>
)
}
}
// This connect the info state from the store to the info property of the App.
export default withRouter(connect(
state => ({
notifications: state.notifications,
configErrors: state.configErrors.errors,
configErrorsReady: state.configErrors.ready,
info: state.info,
tenant: state.tenant,
tenants: state.tenants,
timezone: state.timezone,
user: state.user,
auth: state.auth,
})
)(withAuth(App)))