diff --git a/.neutrinorc.js b/.neutrinorc.js index 95e2113..d91758f 100644 --- a/.neutrinorc.js +++ b/.neutrinorc.js @@ -16,7 +16,6 @@ module.exports = { [ '@neutrinojs/copy', { patterns: [ - { from: 'src/static/fakeOrg.json', to: 'people.json' }, { from: 'src/static/triageOwners.json', to: 'triageOwners.json' }, ], }, diff --git a/package.json b/package.json index f1c5ac7..a6a86c7 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,9 @@ "@material-ui/core": "^3.9.2", "@material-ui/icons": "^3.0.1", "@mozilla-frontend-infra/components": "^2.0.0", + "auth0-js": "9.2.3", "chart.js": "^2.7.3", + "mitt": "^1.1.3", "moment": "^2.23.0", "prop-types": "^15", "query-string": "^6.2.0", @@ -35,11 +37,13 @@ "react-dom": "^16", "react-hot-loader": "^4", "react-router-dom": "^4.3.1", + "taskcluster-client-web": "9.0.0", + "taskcluster-lib-urls": "^12.0.0", "typeface-roboto": "^0.0.54" }, "devDependencies": { "@neutrinojs/airbnb": "^9.0.0-beta.1", - "@neutrinojs/copy": "^8.3.0", + "@neutrinojs/copy": "^9.0.0-beta.1", "@neutrinojs/jest": "^9.0.0-beta.1", "@neutrinojs/react": "^9.0.0-beta.1", "eslint": "^5", diff --git a/src/App/index.jsx b/src/App/index.jsx index d23e090..0f8a788 100644 --- a/src/App/index.jsx +++ b/src/App/index.jsx @@ -1,10 +1,18 @@ import { hot } from 'react-hot-loader'; -import React, { Component } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import { BrowserRouter, Switch, Route } from 'react-router-dom'; import { withStyles } from '@material-ui/core/styles'; import ErrorPanel from '@mozilla-frontend-infra/components/ErrorPanel'; +import Spinner from '@mozilla-frontend-infra/components/Spinner'; + import Main from '../views/Main'; +import PropsRoute from '../components/PropsRoute'; +import AuthContext from '../components/auth/AuthContext'; +import AuthController from '../components/auth/AuthController'; +import NotFound from '../components/NotFound'; +import Auth0Login from '../views/Auth0Login'; +import config from '../config'; const styles = () => ({ '@global': { @@ -14,28 +22,80 @@ const styles = () => ({ }, }, }); -class App extends Component { + +class App extends React.Component { static propTypes = { classes: PropTypes.shape({}).isRequired, }; state = { + authReady: false, error: undefined, }; + authController = new AuthController(); + + componentWillUnmount() { + this.authController.removeListener( + 'user-session-changed', + this.handleUserSessionChanged, + ); + } + + handleUserSessionChanged = (userSession) => { + // Consider auth "ready" when we have no userSession, a userSession with no + // renewAfter, or a renewAfter that is not in the past. Once auth is + // ready, it never becomes non-ready again. + const { authReady } = this.state; + if (!authReady) { + const newState = !userSession + || !userSession.renewAfter + || new Date(userSession.renewAfter) > new Date(); + this.setState({ authReady: newState }); + } + }; + + // eslint-disable-next-line camelcase + UNSAFE_componentWillMount() { + this.authController.on( + 'user-session-changed', + this.handleUserSessionChanged, + ); + + // we do not want to automatically load a user session on the login views; this is + // a hack until they get an entry point of their own with no UI. + if (!window.location.pathname.startsWith(config.redirectRoute)) { + this.authController.loadUserSession(); + } else { + this.setState({ authReady: true }); + } + } + + render() { - const { classes } = this.props; - const { error } = this.state; + const { authReady, error } = this.state; return ( -
- {error && } - - - - - -
+ +
+ {error && } + {authReady ? ( + + + + + + + + ) : ( + + )} +
+
); } } diff --git a/src/components/Header/index.jsx b/src/components/Header/index.jsx new file mode 100644 index 0000000..326308a --- /dev/null +++ b/src/components/Header/index.jsx @@ -0,0 +1,37 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { withStyles } from '@material-ui/core/styles'; +import AppBar from '@material-ui/core/AppBar'; +import Toolbar from '@material-ui/core/Toolbar'; +import Tabs from '@material-ui/core/Tabs'; +import Tab from '@material-ui/core/Tab'; +import CredentialsMenu from '../../views/CredentialsMenu'; + +const styles = theme => ({ + styledToolbar: { + display: 'flex', + justifyContent: 'space-between', + 'min-height': theme.spacing.unit * 1, + }, +}); + +const Header = ({ classes, selectedTabIndex, handleTabChange }) => ( + + + + + + + + + + +); + +Header.propTypes = { + classes: PropTypes.shape({}).isRequired, + selectedTabIndex: PropTypes.number.isRequired, + handleTabChange: PropTypes.func.isRequired, +}; + +export default withStyles(styles)(Header); diff --git a/src/components/MainTabs/index.jsx b/src/components/MainTabs/index.jsx deleted file mode 100644 index 403dc5b..0000000 --- a/src/components/MainTabs/index.jsx +++ /dev/null @@ -1,126 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { withStyles } from '@material-ui/core/styles'; -import AppBar from '@material-ui/core/AppBar'; -import Toolbar from '@material-ui/core/Toolbar'; -import Tabs from '@material-ui/core/Tabs'; -import Tab from '@material-ui/core/Tab'; -import Typography from '@material-ui/core/Typography'; -import BugzillaComponents from '../BugzillaComponents'; -import Reportees from '../Reportees'; - -const TabContainer = (props) => { - const { children } = props; - return ( - - {children} - - ); -}; - -TabContainer.propTypes = { - children: PropTypes.oneOfType([ - PropTypes.arrayOf(PropTypes.node), - PropTypes.node, - ]).isRequired, -}; - - -const styles = theme => ({ - root: { - flexGrow: 1, - backgroundColor: theme.palette.background.paper, - }, - grow: { - flexGrow: 1, - }, - styledToolbar: { - 'min-height': 48, - }, -}); - -class MainTabs extends React.Component { - state = { - selectedTabIndex: 0, - }; - - handleChange = (event, selectedTabIndex) => { - this.setState({ selectedTabIndex }); - }; - - renderTabContents(tabIndex) { - const { - ldapEmail, partialOrg, onPersonDetails, teamComponents, - bugzillaComponents, onComponentDetails, - } = this.props; - switch (tabIndex) { - case 0: - return ( - - ); - case 1: - return ( - - ); - case 2: - return ( - - ); - default: - return null; - } - } - - render() { - const { classes, partialOrg, ldapEmail } = this.props; - const { selectedTabIndex } = this.state; - - return ( -
- - - - - - - -
- - {partialOrg[ldapEmail].cn} - - - - - {this.renderTabContents(selectedTabIndex)} - -
- ); - } -} - -MainTabs.propTypes = { - classes: PropTypes.shape({}).isRequired, - ldapEmail: PropTypes.string.isRequired, - partialOrg: PropTypes.shape({}).isRequired, - bugzillaComponents: PropTypes.arrayOf(PropTypes.shape({})), - teamComponents: PropTypes.arrayOf(PropTypes.shape({})), - onComponentDetails: PropTypes.func.isRequired, - onPersonDetails: PropTypes.func.isRequired, -}; - -MainTabs.defaultProps = { - bugzillaComponents: [], - teamComponents: [], -}; - -export default withStyles(styles)(MainTabs); diff --git a/src/components/MainView/index.jsx b/src/components/MainView/index.jsx index b2094a0..2c27f6e 100644 --- a/src/components/MainView/index.jsx +++ b/src/components/MainView/index.jsx @@ -1,32 +1,50 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { withStyles } from '@material-ui/core/styles'; -import MainTabs from '../MainTabs'; +import BugzillaComponents from '../BugzillaComponents'; +import Reportees from '../Reportees'; -const styles = ({ - content: { - display: 'flex', - }, - header: { - margin: '0.5rem 0 0 0', - }, -}); +class MainView extends React.Component { + renderTabContents() { + const { + ldapEmail, partialOrg, onPersonDetails, teamComponents, + bugzillaComponents, onComponentDetails, selectedTabIndex, + } = this.props; + switch (selectedTabIndex) { + case 0: { + return ( + + ); + } + case 1: { + return ( + + ); + } + case 2: { + return ( + + ); + } + default: { + return null; + } + } + } -const MainView = ({ - ldapEmail, partialOrg, bugzillaComponents, teamComponents, - onComponentDetails, onPersonDetails, -}) => ( -
- -
-); + render() { + return this.renderTabContents(); + } +} MainView.propTypes = { ldapEmail: PropTypes.string.isRequired, @@ -35,6 +53,7 @@ MainView.propTypes = { teamComponents: PropTypes.arrayOf(PropTypes.shape({})), onComponentDetails: PropTypes.func.isRequired, onPersonDetails: PropTypes.func.isRequired, + selectedTabIndex: PropTypes.number.isRequired, }; MainView.defaultProps = { @@ -42,4 +61,4 @@ MainView.defaultProps = { teamComponents: [], }; -export default withStyles(styles)(MainView); +export default MainView; diff --git a/src/components/NotFound/index.jsx b/src/components/NotFound/index.jsx new file mode 100644 index 0000000..5a9d8e9 --- /dev/null +++ b/src/components/NotFound/index.jsx @@ -0,0 +1,4 @@ +import React from 'react'; +import ErrorPanel from '@mozilla-frontend-infra/components/ErrorPanel'; + +export default () => ; diff --git a/src/components/PropsRoute/index.jsx b/src/components/PropsRoute/index.jsx new file mode 100644 index 0000000..6705413 --- /dev/null +++ b/src/components/PropsRoute/index.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { Route } from 'react-router-dom'; + +const PropsRoute = ({ component, ...props }) => ( + React.createElement(component, Object.assign({}, routeProps, props))} + /> +); + +export default PropsRoute; diff --git a/src/components/auth/AuthContext.jsx b/src/components/auth/AuthContext.jsx new file mode 100644 index 0000000..c39fb36 --- /dev/null +++ b/src/components/auth/AuthContext.jsx @@ -0,0 +1,5 @@ +import React from 'react'; + +const AuthContext = React.createContext(); + +export default AuthContext; diff --git a/src/components/auth/AuthController.js b/src/components/auth/AuthController.js new file mode 100644 index 0000000..f7d1984 --- /dev/null +++ b/src/components/auth/AuthController.js @@ -0,0 +1,109 @@ +import mitt from 'mitt'; +import UserSession from './UserSession'; + +import { renew as auth0Renew } from './auth0'; + +/** + * Controller for authentication-related pieces of the site. + * + * This encompasses knowledge of which authentication mechanisms are enabled, including + * credentials menu items, ongoing expiration monitoring, and any additional required UI. + * It also handles synchronizing sign-in status across tabs. + */ +export default class AuthController { + constructor() { + const events = mitt(); + + this.on = events.on; + this.off = events.off; + this.emit = events.emit; + + this.renewalTimer = null; + + window.addEventListener('storage', ({ storageArea, key }) => { + if (storageArea === localStorage && key === 'userSession') { + this.loadUserSession(); + } + }); + } + + /** + * Reset the renewal timer based on the given user session. + */ + resetRenewalTimer(userSession) { + if (this.renewalTimer) { + window.clearTimeout(this.renewalTimer); + this.renewalTimer = null; + } + + if (userSession && userSession.renewAfter) { + let timeout = Math.max(0, new Date(userSession.renewAfter) - new Date()); + + // if the timeout is in the future, apply up to a few minutes to it + // randomly. This avoids multiple tabs all trying to renew at the + // same time. + if (timeout > 0) { + timeout += Math.random() * 5 * 60 * 1000; + } + + this.renewalTimer = window.setTimeout(() => { + this.renewalTimer = null; + this.renew({ userSession }); + }, timeout); + } + } + + /** + * Load the current user session (from localStorage). + * + * This will emit the user-session-changed event, but does not + * return the user session. + */ + loadUserSession() { + const storedUserSession = localStorage.getItem('userSession'); + const userSession = storedUserSession + ? UserSession.deserialize(storedUserSession) + : null; + + this.userSession = userSession; + this.resetRenewalTimer(userSession); + this.emit('user-session-changed', userSession); + } + + /** + * Get the current userSession instance + */ + getUserSession() { + return this.userSession; + } + + /** + * Set the current user session, or (if null) delete the current user session. + * + * This will change the user session in all open windows/tabs, eventually triggering + * a call to any onSessionChanged callbacks. + */ + setUserSession = (userSession) => { + if (!userSession) { + localStorage.removeItem('userSession'); + } else { + localStorage.setItem('userSession', userSession.serialize()); + } + + // localStorage updates do not trigger event listeners on the current window/tab, + // so invoke it directly + this.loadUserSession(); + }; + + /** + * Renew the user session. This is not possible for all auth methods, and will trivially succeed + * for methods that do not support it. If it fails, the user will be logged out. + */ + async renew({ userSession }) { + try { + await auth0Renew({ userSession, authController: this }); + } catch (err) { + this.setUserSession(null); + } + } +} diff --git a/src/components/auth/UserSession.js b/src/components/auth/UserSession.js new file mode 100644 index 0000000..e6e2d06 --- /dev/null +++ b/src/components/auth/UserSession.js @@ -0,0 +1,102 @@ +import { OIDCCredentialAgent, Secrets } from 'taskcluster-client-web'; +import { withRootUrl } from 'taskcluster-lib-urls'; + +const urls = withRootUrl('https://taskcluster.net'); +/** + * An object representing a user session. Tools supports a variety of login methods, + * so this combines them all in a single representation. + * + * UserSessions are immutable -- when anything about the session changes, a new instance + * replaces the old. The `userChanged` method is useful to distinguish changes to the + * user identity from mere token renewals. + * + * Common properties are: + * + * - type - 'oidc' or 'credentials' + * - name - user name + * - clientArgs - arguments to pass to taskcluster-client-web Client constructors + * - renewAfter - date (Date or string) after which this session should be renewed, + * if applicable + * + * When type is 'oidc': + * + * - oidcProvider -- the provider (see taskcluster-login) + * - accessToken -- the accessToken to pass to taskcluster-login + * - fullName -- user's full name + * - picture -- URL of an image of the user + * - oidcSubject -- the 'sub' field of the id_token (useful for debugging user issues) + * + * When the type is 'credentials': + * + * - credentials -- the Taskcluster credentials (with or without a certificate) + * + * To fetch Taskcluster credentials for the user regardless of type, use the getCredentials + * method. + */ +export default class UserSession { + constructor(options) { + Object.assign(this, options); + + if (this.accessToken) { + this.credentialAgent = new OIDCCredentialAgent({ + accessToken: this.accessToken, + url: urls.api('login', 'v1', `/oidc-credentials/${this.oidcProvider}`), + }); + } + } + + static fromCredentials(credentials) { + return new UserSession({ type: 'credentials', credentials }); + } + + static fromOIDC(options) { + return new UserSession({ type: 'oidc', ...options }); + } + + // determine whether the user changed from old to new; this is used by other components + // to determine when to update in response to a sign-in/sign-out event + static userChanged(oldUser, newUser) { + if (!oldUser && !newUser) { + return false; + } + + if (!oldUser || !newUser) { + return true; + } + + return oldUser.type !== newUser.type || oldUser.name !== newUser.name; + } + + // get the user's name + get name() { + return ( + this.fullName + || (this.credentials && this.credentials.clientId) + || 'unknown' + ); + } + + // get the args used to create a new client object + get clientArgs() { + return this.credentialAgent + ? { credentialAgent: this.credentialAgent } + : { credentials: this.credentials }; + } + + // load Taskcluster credentials for this user + getCredentials() { + return this.credentials + ? Promise.resolve(this.credentials) + : this.credentialAgent.getCredentials({}); + } + + static deserialize(value) { + return new UserSession(JSON.parse(value)); + } + + serialize() { + return JSON.stringify({ ...this, credentialAgent: undefined }); + } + + getTaskClusterSecretsClient = () => new Secrets({ ...this.clientArgs, rootUrl: 'https://taskcluster.net' }); +} diff --git a/src/components/auth/auth0.js b/src/components/auth/auth0.js new file mode 100644 index 0000000..ebf6243 --- /dev/null +++ b/src/components/auth/auth0.js @@ -0,0 +1,40 @@ +import { fromNow } from 'taskcluster-client-web'; +import { WebAuth } from 'auth0-js'; +import UserSession from './UserSession'; +import config from '../../config'; + +export const webAuth = new WebAuth(config.auth0Options); + +export function userSessionFromAuthResult(authResult) { + return UserSession.fromOIDC({ + oidcProvider: 'mozilla-auth0', + accessToken: authResult.accessToken, + fullName: authResult.idTokenPayload.name, + email: authResult.idTokenPayload.email, + picture: authResult.idTokenPayload.picture, + oidcSubject: authResult.idTokenPayload.sub, + // per https://wiki.mozilla.org/Security/Guidelines/OpenID_connect#Session_handling + renewAfter: fromNow('15 minutes'), + }); +} + +/* eslint-disable consistent-return */ +export async function renew({ userSession, authController }) { + if ( + !userSession + || userSession.type !== 'oidc' + || userSession.oidcProvider !== 'mozilla-auth0' + ) { + return; + } + + return new Promise((accept, reject) => webAuth.renewAuth({}, (err, authResult) => { + if (err) { + return reject(err); + } if (!authResult) { + return reject(new Error('no authResult')); + } + authController.setUserSession(userSessionFromAuthResult(authResult)); + accept(); + })); +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..34c9ab4 --- /dev/null +++ b/src/config.js @@ -0,0 +1,18 @@ +const loginCallbackRoute = '/callback'; + +const config = { + redirectRoute: loginCallbackRoute, + taskclusterSecrets: { + orgData: 'project/bugzilla-management-dashboard/realOrg', + }, + auth0Options: { + domain: 'auth.mozilla.auth0.com', + clientID: 'DGloMN2BXb0AC7lF5eRyOe1GXweqBAiI', + redirectUri: new URL(loginCallbackRoute, window.location).href, + scope: 'taskcluster-credentials full-user-credentials openid profile email', + audience: 'login.taskcluster.net', + responseType: 'token id_token', + }, +}; + +export default config; diff --git a/src/containers/MainContainer/index.jsx b/src/containers/MainContainer/index.jsx deleted file mode 100644 index 4075809..0000000 --- a/src/containers/MainContainer/index.jsx +++ /dev/null @@ -1,210 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import MainView from '../../components/MainView'; -import BugzillaComponentDetails from '../../components/BugzillaComponentDetails'; -import PersonDetails from '../../components/PersonDetails'; -import getAllReportees from '../../utils/getAllReportees'; -import getBugzillaOwners from '../../utils/getBugzillaOwners'; -import getBugsCountAndLink from '../../utils/bugzilla/getBugsCountAndLink'; -import METRICS from '../../utils/bugzilla/metrics'; -import TEAMS_CONFIG from '../../teamsConfig'; - -class MainContainer extends Component { - state = { - ldapEmail: '', - bugzillaComponents: {}, - partialOrg: undefined, - teamComponents: {}, - showComponent: undefined, - showPerson: undefined, - }; - - static propTypes = { - ldapEmail: PropTypes.string, - }; - - static defaultProps = { - ldapEmail: '', - }; - - constructor(props) { - super(props); - const { ldapEmail } = this.props; - this.state.ldapEmail = ldapEmail; - this.handleChange = this.handleChange.bind(this); - this.handleSubmit = this.handleSubmit.bind(this); - this.handleShowComponentDetails = this.handleShowComponentDetails.bind(this); - this.handleShowPersonDetails = this.handleShowPersonDetails.bind(this); - this.handleComponentBackToMenu = this.handleComponentBackToMenu.bind(this); - } - - async componentDidMount() { - const { ldapEmail } = this.state; - if (ldapEmail !== '') { - this.retrieveData(ldapEmail); - } - } - - async getReportees(ldapEmail) { - const partialOrg = await getAllReportees(ldapEmail); - this.setState({ partialOrg }); - return partialOrg; - } - - async bugzillaComponents(bzOwners, partialOrg) { - // bzOwners uses the bugzilla email address as the key - // while partialOrg uses the LDAP email address - /* eslint-disable no-param-reassign */ - const bugzillaComponents = Object.values(partialOrg) - .reduce((result, { bugzillaEmail, mail }) => { - const componentsOwned = bzOwners[bugzillaEmail] || bzOwners[mail]; - if (componentsOwned) { - componentsOwned.forEach(({ product, component }) => { - if (!result[`${product}::${component}`]) { - result[`${product}::${component}`] = {}; - } - result[`${product}::${component}`] = { - label: `${product}::${component}`, - bugzillaEmail: bugzillaEmail || mail, - product, - component, - metrics: {}, - }; - }); - } - return result; - }, {}); - /* eslint-enable no-param-reassign */ - // This will list the components but will not show metrics - this.setState({ bugzillaComponents }); - - // Let's fetch the metrics for each component - Object.values(bugzillaComponents) - .map(async ({ product, component }) => { - const { metrics } = bugzillaComponents[`${product}::${component}`]; - await Promise.all(Object.keys(METRICS).map(async (metric) => { - metrics[metric] = await getBugsCountAndLink(product, component, metric); - metrics[metric].label = METRICS[metric].label; - })); - this.setState({ bugzillaComponents }); - }); - } - - async retrieveData(ldapEmail) { - const [bzOwners, partialOrg] = await Promise.all([ - getBugzillaOwners(), - this.getReportees(ldapEmail), - ]); - this.teamsData(); - this.bugzillaComponents(bzOwners, partialOrg); - } - - async teamsData() { - const teamComponents = Object.assign({}, TEAMS_CONFIG); - // This will cause the teams to be displayed before having any metrics - this.setState({ teamComponents }); - Object.entries(teamComponents).map(async ([teamKey, teamInfo]) => { - const team = { - teamKey, - ...teamInfo, - metrics: {}, - }; - const { product, component } = teamInfo; - await Promise.all(Object.keys(METRICS).map(async (metric) => { - team.metrics[metric] = await getBugsCountAndLink(product, component, metric); - })); - teamComponents[teamKey] = team; - this.setState({ teamComponents }); - }); - } - - handleChange(event) { - this.setState({ - ldapEmail: event.target.selectedTabIndex, - bugzillaComponents: undefined, - partialOrg: undefined, - }); - } - - async handleSubmit(event) { - event.preventDefault(); - const { ldapEmail } = this.state; - this.retrieveData(ldapEmail); - } - - handleShowComponentDetails(event, properties) { - event.preventDefault(); - const { componentKey, teamKey } = properties; - // IDEA: In the future we could unify bugzilla components and teams into - // the same data structure and make this logic simpler. We could use a - // property 'team' to distinguish a component from a set of components - if (teamKey) { - this.setState(prevState => ({ - showComponent: { - title: prevState.teamComponents[teamKey].label, - ...prevState.teamComponents[teamKey], - }, - })); - } else { - this.setState(prevState => ({ - showComponent: { - title: componentKey, - ...prevState.bugzillaComponents[componentKey], - }, - })); - } - } - - handleShowPersonDetails(event, properties) { - event.preventDefault(); - const { partialOrg } = this.state; - this.setState({ - showPerson: partialOrg[properties.ldapEmail], - }); - } - - handleComponentBackToMenu(event) { - event.preventDefault(); - this.setState({ - showComponent: undefined, - showPerson: undefined, - }); - } - - render() { - const { - ldapEmail, showComponent, showPerson, bugzillaComponents, partialOrg, teamComponents, - } = this.state; - - return ( -
- {showComponent && ( - - )} - {showPerson && ( - - )} - {!showComponent && !showPerson && partialOrg && ( - - )} -
- ); - } -} - -export default MainContainer; diff --git a/src/utils/getAllReportees.js b/src/utils/getAllReportees.js index 0a96df2..4f6200e 100644 --- a/src/utils/getAllReportees.js +++ b/src/utils/getAllReportees.js @@ -1,9 +1,45 @@ -import getOrgChart from './getOrgChart'; +import config from '../config'; -const findReportees = (completeOrg, ldapEmail) => { +const buildOrgChartData = (people) => { + const org = {}; + people.forEach((person) => { + const { mail } = person; + if (!org[mail]) { + org[mail] = person; + org[mail].reportees = []; + } else { + org[mail] = { + ...person, + reportees: org[mail].reportees, + }; + } + const { manager } = person; + if (manager) { + const managerLDAPemail = manager.dn.split('mail=')[1].split(',o=')[0]; + if (org[managerLDAPemail]) { + org[managerLDAPemail].reportees.push(mail); + } else { + org[managerLDAPemail] = { + reportees: [mail], + }; + } + } + if (!org[mail].bugzillaEmail) { + org[mail].bugzillaEmail = mail; + } + }); + return org; +}; + +const getOrgChart = async (secretsClient) => { + const { secret } = await await secretsClient.get(config.taskclusterSecrets.orgData); + return buildOrgChartData(secret.employees); +}; + +const findReportees = (completeOrg, email) => { let allReportees = {}; - allReportees[ldapEmail] = completeOrg[ldapEmail]; - const { reportees } = completeOrg[ldapEmail]; + allReportees[email] = completeOrg[email]; + const { reportees } = completeOrg[email]; if (reportees.length !== 0) { reportees.forEach((reporteeEmail) => { const partialOrg = findReportees(completeOrg, reporteeEmail); @@ -13,8 +49,8 @@ const findReportees = (completeOrg, ldapEmail) => { return allReportees; }; -const getAllReportees = async (ldapEmail) => { - const completeOrg = await getOrgChart(); +const getAllReportees = async (secretsClient, ldapEmail) => { + const completeOrg = await getOrgChart(secretsClient); return findReportees(completeOrg, ldapEmail); }; diff --git a/src/utils/getOrgChart.js b/src/utils/getOrgChart.js deleted file mode 100644 index cd5a72c..0000000 --- a/src/utils/getOrgChart.js +++ /dev/null @@ -1,37 +0,0 @@ -const orgChart = (people) => { - const org = {}; - people.forEach((person) => { - const { mail } = person; - if (!org[mail]) { - org[mail] = person; - org[mail].reportees = []; - } else { - org[mail] = { - ...person, - reportees: org[mail].reportees, - }; - } - const { manager } = person; - if (manager) { - const managerLDAPemail = manager.dn.split('mail=')[1].split(',o=')[0]; - if (org[managerLDAPemail]) { - org[managerLDAPemail].reportees.push(mail); - } else { - org[managerLDAPemail] = { - reportees: [mail], - }; - } - } - if (!org[mail].bugzillaEmail) { - org[mail].bugzillaEmail = mail; - } - }); - return org; -}; - -const getOrgChart = async () => { - const people = await (await fetch('people.json')).json(); - return orgChart(people); -}; - -export default getOrgChart; diff --git a/src/views/Auth0Login/index.jsx b/src/views/Auth0Login/index.jsx new file mode 100644 index 0000000..b25e5e2 --- /dev/null +++ b/src/views/Auth0Login/index.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ErrorPanel from '@mozilla-frontend-infra/components/ErrorPanel'; +import { webAuth, userSessionFromAuthResult } from '../../components/auth/auth0'; + +export default class Auth0Login extends React.PureComponent { + static propTypes = { + history: PropTypes.shape({}).isRequired, + setUserSession: PropTypes.func.isRequired, + }; + + state = {}; + + componentDidMount() { + const { history, setUserSession } = this.props; + + if (!window.location.hash) { + webAuth.authorize(); + } else if (window !== window.top) { + // for silent renewal, auth0-js opens this page in an iframe, and expects + // a postMessage back, and that's it. + window.parent.postMessage(window.location.hash, window.origin); + } else { + webAuth.parseHash(window.location.hash, (loginError, authResult) => { + if (loginError) { + this.setState({ loginError }); + } else { + setUserSession(userSessionFromAuthResult(authResult)); + if (window.opener) { + window.close(); + } else { + history.push('/'); + } + } + }); + } + } + + render() { + const { loginError } = this.state; + if (loginError) { + return ; + } + + if (window.location.hash) { + return

Logging in..

; + } + + return

Redirecting..

; + } +} diff --git a/src/views/CredentialsMenu/index.jsx b/src/views/CredentialsMenu/index.jsx new file mode 100644 index 0000000..920bebf --- /dev/null +++ b/src/views/CredentialsMenu/index.jsx @@ -0,0 +1,83 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { withStyles } from '@material-ui/core/styles'; +import Button from '@material-ui/core/Button'; + +import AuthContext from '../../components/auth/AuthContext'; +import config from '../../config'; + +const styles = theme => ({ + button: { + margin: theme.spacing.unit, + }, +}); + +class CredentialsMenu extends React.PureComponent { + static contextType = AuthContext; + + static propTypes = { + classes: PropTypes.shape({}).isRequired, + }; + + static handleLoginRequest() { + const loginView = new URL(config.redirectRoute, window.location); + window.open(loginView, '_blank'); + } + + componentDidMount() { + const { context } = this; + if (context) { + context.on( + 'user-session-changed', + this.handleUserSessionChanged, + ); + } + } + + componentWillUnmount() { + const { context } = this.context; + if (context) { + context.off( + 'user-session-changed', + this.handleUserSessionChanged, + ); + } + } + + handleUserSessionChanged = () => { + this.forceUpdate(); + }; + + render() { + // note: an update to the userSession will cause a forceUpdate + const { context } = this; + const { classes } = this.props; + const userSession = context && context.getUserSession(); + + return ( + userSession ? ( + + ) : ( + + ) + ); + } +} + +export default withStyles(styles)(CredentialsMenu); diff --git a/src/views/Main/index.jsx b/src/views/Main/index.jsx index 8bed810..f802143 100644 --- a/src/views/Main/index.jsx +++ b/src/views/Main/index.jsx @@ -1,12 +1,239 @@ -import React from 'react'; -import MainContainer from '../../containers/MainContainer'; +import React, { Component } from 'react'; +import AuthContext from '../../components/auth/AuthContext'; +import Header from '../../components/Header'; +import MainView from '../../components/MainView'; +import BugzillaComponentDetails from '../../components/BugzillaComponentDetails'; +import PersonDetails from '../../components/PersonDetails'; +import getAllReportees from '../../utils/getAllReportees'; +import getBugzillaOwners from '../../utils/getBugzillaOwners'; +import getBugsCountAndLink from '../../utils/bugzilla/getBugsCountAndLink'; +import METRICS from '../../utils/bugzilla/metrics'; +import TEAMS_CONFIG from '../../teamsConfig'; -// eslint-disable-next-line react/prop-types -const Main = ({ location }) => ( - -); +const DEFAULT_STATE = { + bugzillaComponents: {}, + partialOrg: undefined, + teamComponents: {}, + selectedTabIndex: 0, + showComponent: undefined, + showPerson: undefined, +}; -export default Main; +class MainContainer extends Component { + static contextType = AuthContext; + + state = DEFAULT_STATE; + + constructor(props) { + super(props); + this.handleShowComponentDetails = this.handleShowComponentDetails.bind(this); + this.handleShowPersonDetails = this.handleShowPersonDetails.bind(this); + this.handleComponentBackToMenu = this.handleComponentBackToMenu.bind(this); + } + + componentDidMount() { + const { context } = this; + if (context) { + context.on( + 'user-session-changed', + this.handleUserSessionChanged, + ); + this.fetchData(); + } + } + + componentWillUnmount() { + const { context } = this.context; + if (context) { + context.off( + 'user-session-changed', + this.handleUserSessionChanged, + ); + } + } + + async getReportees(userSession, ldapEmail) { + const secretsClient = userSession.getTaskClusterSecretsClient(); + const partialOrg = await getAllReportees(secretsClient, ldapEmail); + this.setState({ partialOrg }); + return partialOrg; + } + + handleChangeSelectedTab = (event, selectedTabIndex) => { + this.setState({ selectedTabIndex }); + }; + + handleUserSessionChanged = () => { + this.fetchData(); + }; + + fetchData() { + const { context } = this; + const userSession = context && context.getUserSession(); + if (userSession) { + const { location } = this.props; + const ldapEmail = new URLSearchParams(location.search).get('ldapEmail') || (userSession && userSession.email); + this.setState({ ldapEmail }); + this.retrieveData(userSession, ldapEmail); + } else { + this.setState(DEFAULT_STATE); + } + } + + async bugzillaComponents(bzOwners, partialOrg) { + // bzOwners uses the bugzilla email address as the key + // while partialOrg uses the LDAP email address + /* eslint-disable no-param-reassign */ + const bugzillaComponents = Object.values(partialOrg) + .reduce((result, { bugzillaEmail, mail }) => { + const componentsOwned = bzOwners[bugzillaEmail] || bzOwners[mail]; + if (componentsOwned) { + componentsOwned.forEach(({ product, component }) => { + if (!result[`${product}::${component}`]) { + result[`${product}::${component}`] = {}; + } + result[`${product}::${component}`] = { + label: `${product}::${component}`, + bugzillaEmail: bugzillaEmail || mail, + product, + component, + metrics: {}, + }; + }); + } + return result; + }, {}); + /* eslint-enable no-param-reassign */ + // This will list the components but will not show metrics + this.setState({ bugzillaComponents }); + + // Let's fetch the metrics for each component + Object.values(bugzillaComponents) + .map(async ({ product, component }) => { + const { metrics } = bugzillaComponents[`${product}::${component}`]; + await Promise.all(Object.keys(METRICS).map(async (metric) => { + metrics[metric] = await getBugsCountAndLink(product, component, metric); + metrics[metric].label = METRICS[metric].label; + })); + this.setState({ bugzillaComponents }); + }); + } + + async retrieveData(userSession, ldapEmail) { + const [bzOwners, partialOrg] = await Promise.all([ + getBugzillaOwners(), + this.getReportees(userSession, ldapEmail), + ]); + this.teamsData(); + this.bugzillaComponents(bzOwners, partialOrg); + } + + async teamsData() { + const teamComponents = Object.assign({}, TEAMS_CONFIG); + // This will cause the teams to be displayed before having any metrics + this.setState({ teamComponents }); + Object.entries(teamComponents).map(async ([teamKey, teamInfo]) => { + const team = { + teamKey, + ...teamInfo, + metrics: {}, + }; + const { product, component } = teamInfo; + await Promise.all(Object.keys(METRICS).map(async (metric) => { + team.metrics[metric] = await getBugsCountAndLink(product, component, metric); + })); + teamComponents[teamKey] = team; + this.setState({ teamComponents }); + }); + } + + handleShowComponentDetails(event, properties) { + event.preventDefault(); + const { componentKey, teamKey } = properties; + // IDEA: In the future we could unify bugzilla components and teams into + // the same data structure and make this logic simpler. We could use a + // property 'team' to distinguish a component from a set of components + if (teamKey) { + this.setState(prevState => ({ + showComponent: { + title: prevState.teamComponents[teamKey].label, + ...prevState.teamComponents[teamKey], + }, + })); + } else { + this.setState(prevState => ({ + showComponent: { + title: componentKey, + ...prevState.bugzillaComponents[componentKey], + }, + })); + } + } + + handleShowPersonDetails(event, properties) { + event.preventDefault(); + const { partialOrg } = this.state; + this.setState({ + showPerson: partialOrg[properties.ldapEmail], + }); + } + + handleComponentBackToMenu(event) { + event.preventDefault(); + this.setState({ + showComponent: undefined, + showPerson: undefined, + }); + } + + render() { + const { + showComponent, + showPerson, + bugzillaComponents, + ldapEmail, + partialOrg, + teamComponents, + selectedTabIndex, + } = this.state; + const { context } = this; + const userSession = context.getUserSession(); + + return ( +
+
+ {!userSession &&

Please sign in

} + {showComponent && ( + + )} + {showPerson && ( + + )} + {!showComponent && !showPerson && partialOrg && userSession && ( + + )} +
+ ); + } +} + +export default MainContainer; diff --git a/test/components/MainView.test.jsx b/test/components/MainView.test.jsx index b188fd2..b2ecb07 100644 --- a/test/components/MainView.test.jsx +++ b/test/components/MainView.test.jsx @@ -15,6 +15,7 @@ it('renders Someone with no reportees', () => { teams={{}} onComponentDetails={() => null} onPersonDetails={() => null} + selectedTabIndex={0} /> )) .toJSON(); @@ -31,6 +32,7 @@ it('renders Manager who has reportees', () => { teams={{}} onComponentDetails={() => null} onPersonDetails={() => null} + selectedTabIndex={0} /> )) .toJSON(); @@ -47,6 +49,7 @@ it('renders Manager who has reportees and teams', () => { teams={Object.values(teamsConfig)} onComponentDetails={() => null} onPersonDetails={() => null} + selectedTabIndex={0} /> )) .toJSON(); diff --git a/test/components/__snapshots__/MainView.test.jsx.snap b/test/components/__snapshots__/MainView.test.jsx.snap index 4aca00f..db54b19 100644 --- a/test/components/__snapshots__/MainView.test.jsx.snap +++ b/test/components/__snapshots__/MainView.test.jsx.snap @@ -1,745 +1,223 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`renders Manager who has reportees 1`] = ` -
+
+   +
+
-
-
-
-
-
-
- - - -
- -
-
-
-
-
- Manager -
-
-
- + + +
+ + Manager + +
+
+
+ +
+ + Someone +
`; exports[`renders Manager who has reportees and teams 1`] = ` -
+
+   +
+
-
-
-
-
-
-
- - - -
- -
-
-
-
-
- Manager -
-
-
- + + +
+ + Manager + +
+
+
+ +
+ + Someone +
`; exports[`renders Someone with no reportees 1`] = ` -
+
+   +
+
-
-
-
-
-
-
- - - -
- -
-
-
-
-
- Someone -
-
-
- + + +
+ + Manager + +
+
+
+ +
+ + Someone +
`; diff --git a/yarn.lock b/yarn.lock index af4159a..cf79e9c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -743,17 +743,6 @@ "@babel/runtime" "7.0.0" recompose "^0.29.0" -"@material-ui/lab@^3.0.0-alpha.30": - version "3.0.0-alpha.30" - resolved "https://registry.yarnpkg.com/@material-ui/lab/-/lab-3.0.0-alpha.30.tgz#c6c64d0ff2b28410a09e4009f3677499461f3df8" - integrity sha512-d8IXbkQO92Ln7f/Tzy8Q5cLi/sMWH/Uz1xrOO5NKUgg42whwyCuoT9ErddDPFNQmPi9d1C7A5AG8ONjEAbAIyQ== - dependencies: - "@babel/runtime" "^7.2.0" - "@material-ui/utils" "^3.0.0-alpha.2" - classnames "^2.2.5" - keycode "^2.1.9" - prop-types "^15.6.0" - "@material-ui/system@^3.0.0-alpha.0": version "3.0.0-alpha.2" resolved "https://registry.yarnpkg.com/@material-ui/system/-/system-3.0.0-alpha.2.tgz#096e80c8bb0f70aea435b9e38ea7749ee77b4e46" @@ -822,13 +811,12 @@ babel-loader "^8.0.4" babel-merge "^2.0.1" -"@neutrinojs/copy@^8.3.0": - version "8.3.0" - resolved "https://registry.yarnpkg.com/@neutrinojs/copy/-/copy-8.3.0.tgz#d41a7124b677103134063ef1a4f2a671da1b7758" - integrity sha1-1BpxJLZ3EDE0Bj7xpPKmcdobd1g= +"@neutrinojs/copy@^9.0.0-beta.1": + version "9.0.0-rc.0" + resolved "https://registry.yarnpkg.com/@neutrinojs/copy/-/copy-9.0.0-rc.0.tgz#9463e6557338a8433afff58fbaba206d38f2039b" + integrity sha512-/Ucrl+jHi1lCbVtQ9tVPijLgP/vEjxWSORjTE9DF22BsyVv0HUPcWiWBj+Ahv3ZFS7B2gmMx6CK056OK+uqMmg== dependencies: - copy-webpack-plugin "^4.5.1" - deepmerge "^1.5.2" + copy-webpack-plugin "^4.6.0" "@neutrinojs/dev-server@9.0.0-beta.1": version "9.0.0-beta.1" @@ -1430,6 +1418,18 @@ atob@^2.1.1: resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== +auth0-js@9.2.3: + version "9.2.3" + resolved "https://registry.yarnpkg.com/auth0-js/-/auth0-js-9.2.3.tgz#f98650a742c83567e887703c5972d0c7275e403d" + integrity sha1-+YZQp0LINWfoh3A8WXLQxydeQD0= + dependencies: + base64-js "^1.2.0" + idtoken-verifier "^1.1.1" + qs "^6.4.0" + superagent "^3.8.2" + url-join "^1.1.0" + winchan "^0.2.0" + aws-sign2@~0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" @@ -1447,6 +1447,13 @@ axobject-query@^2.0.1: dependencies: ast-types-flow "0.0.7" +b64@4.x.x: + version "4.1.2" + resolved "https://registry.yarnpkg.com/b64/-/b64-4.1.2.tgz#7015372ba8101f7fb18da070717a93c28c8580d8" + integrity sha512-+GUspBxlH3CJaxMUGUE1EBoWM6RKgWiYwUDal0qdf8m3ArnXNN1KzKVo5HOnE/FSq4HHyWf3TlHLsZI8PKQgrQ== + dependencies: + hoek "6.x.x" + babel-code-frame@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" @@ -1649,7 +1656,7 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= -base64-js@^1.0.2: +base64-js@^1.0.2, base64-js@^1.2.0: version "1.3.0" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.0.tgz#cab1e6118f051095e58b5281aea8c1cd22bfc0e3" integrity sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw== @@ -1732,6 +1739,21 @@ boolbase@~1.0.0: resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24= +boom@7.x.x: + version "7.3.0" + resolved "https://registry.yarnpkg.com/boom/-/boom-7.3.0.tgz#733a6d956d33b0b1999da3fe6c12996950d017b9" + integrity sha512-Swpoyi2t5+GhOEGw8rEsKvTxFLIDiiKoUc2gsoV6Lyr43LHBIzch3k2MvYUs8RTROrIkVJ3Al0TkaOGjnb+B6A== + dependencies: + hoek "6.x.x" + +bounce@1.x.x: + version "1.2.3" + resolved "https://registry.yarnpkg.com/bounce/-/bounce-1.2.3.tgz#2b286d36eb21d5f08fe672dd8cd37a109baad121" + integrity sha512-3G7B8CyBnip5EahCZJjnvQ1HLyArC6P5e+xcolo13BVI9ogFaDOsNMAE7FIWliHtIkYI8/nTRCvCY9tZa3Mu4g== + dependencies: + boom "7.x.x" + hoek "6.x.x" + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -2249,7 +2271,7 @@ commondir@^1.0.1: resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= -component-emitter@^1.2.1: +component-emitter@^1.2.0, component-emitter@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY= @@ -2343,6 +2365,11 @@ cookie@0.3.1: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb" integrity sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s= +cookiejar@^2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.2.tgz#dd8a235530752f988f9a0844f3fc589e3111125c" + integrity sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA== + copy-concurrently@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0" @@ -2360,7 +2387,7 @@ copy-descriptor@^0.1.0: resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= -copy-webpack-plugin@^4.5.1: +copy-webpack-plugin@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-4.6.0.tgz#e7f40dd8a68477d405dd1b7a854aae324b158bae" integrity sha512-Y+SQCF+0NoWQryez2zXn5J5knmr9z/9qSQt7fbL78u83rxmigOy8X5+BFn8CFSuX+nKT8gpYwJX68ekqtQt6ZA== @@ -2459,6 +2486,13 @@ cross-spawn@^6.0.0, cross-spawn@^6.0.5: shebang-command "^1.2.0" which "^1.2.9" +cryptiles@4.x.x: + version "4.1.3" + resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-4.1.3.tgz#2461d3390ea0b82c643a6ba79f0ed491b0934c25" + integrity sha512-gT9nyTMSUC1JnziQpPbxKGBbUg8VL7Zn2NB4E1cJYvuXdElHrwxrV9bmltZGDzet45zSDGyYceueke1TjynGzw== + dependencies: + boom "7.x.x" + crypto-browserify@^3.11.0: version "3.12.0" resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.12.0.tgz#396cf9f3137f03e4b8e532c58f698254e00f80ec" @@ -2476,6 +2510,11 @@ crypto-browserify@^3.11.0: randombytes "^2.0.0" randomfill "^1.0.3" +crypto-js@^3.1.9-1: + version "3.1.9-1" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-3.1.9-1.tgz#fda19e761fc077e01ffbfdc6e9fdfc59e8806cd8" + integrity sha1-/aGedh/Ad+Af+/3G6f38WeiAbNg= + css-loader@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-1.0.1.tgz#6885bb5233b35ec47b006057da01cc640b6b79fe" @@ -3473,7 +3512,7 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2: assign-symbols "^1.0.0" is-extendable "^1.0.1" -extend@~3.0.2: +extend@^3.0.0, extend@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== @@ -3766,7 +3805,7 @@ forever-agent@~0.6.1: resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= -form-data@~2.3.2: +form-data@^2.3.1, form-data@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ== @@ -3775,6 +3814,11 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" +formidable@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.1.tgz#70fb7ca0290ee6ff961090415f4b3df3d2082659" + integrity sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg== + forwarded@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84" @@ -4127,6 +4171,17 @@ hash.js@^1.0.0, hash.js@^1.0.3: inherits "^2.0.3" minimalistic-assert "^1.0.1" +hawk@^7.0.7: + version "7.0.10" + resolved "https://registry.yarnpkg.com/hawk/-/hawk-7.0.10.tgz#960f72edac9c6b9114c8387886d7278fba9119eb" + integrity sha512-3RWF4SXN9CdZ1VDAe6Pn3Rd0tC3Lw+GV+esX5oKCrXoScZK3Ri6dl5Wt986M/hlzU+GuapTGiB0rBhGeRIBQsw== + dependencies: + b64 "4.x.x" + boom "7.x.x" + cryptiles "4.x.x" + hoek "6.x.x" + sntp "3.x.x" + he@1.2.x: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" @@ -4157,6 +4212,11 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" +hoek@6.x.x: + version "6.1.3" + resolved "https://registry.yarnpkg.com/hoek/-/hoek-6.1.3.tgz#73b7d33952e01fe27a38b0457294b79dd8da242c" + integrity sha512-YXXAAhmF9zpQbC7LEcREFtXfGq5K1fmd+4PHkBq8NUqmzW3G+Dq10bI/i0KucLRwss3YYFQ0fSfoxBZYiGUqtQ== + hoist-non-react-statics@^2.3.1, hoist-non-react-statics@^2.5.0: version "2.5.5" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47" @@ -4357,6 +4417,17 @@ icss-utils@^2.1.0: dependencies: postcss "^6.0.1" +idtoken-verifier@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/idtoken-verifier/-/idtoken-verifier-1.2.0.tgz#4654f1f07ab7a803fc9b1b8b36057e2a87ad8b09" + integrity sha512-8jmmFHwdPz8L73zGNAXHHOV9yXNC+Z0TUBN5rafpoaFaLFltlIFr1JkQa3FYAETP23eSsulVw0sBiwrE8jqbUg== + dependencies: + base64-js "^1.2.0" + crypto-js "^3.1.9-1" + jsbn "^0.1.0" + superagent "^3.8.2" + url-join "^1.1.0" + ieee754@^1.1.4: version "1.1.12" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.12.tgz#50bf24e5b9c8bb98af4964c941cdb0918da7b60b" @@ -5279,7 +5350,7 @@ js-yaml@^3.12.0, js-yaml@^3.7.0, js-yaml@^3.9.0: argparse "^1.0.7" esprima "^4.0.0" -jsbn@~0.1.0: +jsbn@^0.1.0, jsbn@~0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= @@ -5442,11 +5513,6 @@ jsx-ast-utils@^2.0.1: dependencies: array-includes "^3.0.3" -keycode@^2.1.9: - version "2.2.0" - resolved "https://registry.yarnpkg.com/keycode/-/keycode-2.2.0.tgz#3d0af56dc7b8b8e5cba8d0a97f107204eec22b04" - integrity sha1-PQr1bce4uOXLqNCpfxByBO7CKwQ= - killable@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892" @@ -5866,7 +5932,7 @@ merge@^1.2.0: resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.1.tgz#38bebf80c3220a8a487b6fcfb3941bb11720c145" integrity sha512-VjFo4P5Whtj4vsLzsYBu5ayHhoHJ0UqNm7ibvShmbmoz7tGi0vXaoJbGdB+GmDMLUdg8DpQXEIeVDAe8MaABvQ== -methods@~1.1.2: +methods@^1.1.1, methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= @@ -5934,6 +6000,11 @@ mime@1.4.1: resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6" integrity sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ== +mime@^1.4.1: + version "1.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + mime@^2.0.3, mime@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/mime/-/mime-2.3.1.tgz#b1621c54d63b97c47d3cfe7f7215f7d64517c369" @@ -6039,6 +6110,11 @@ mississippi@^3.0.0: stream-each "^1.1.0" through2 "^2.0.0" +mitt@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/mitt/-/mitt-1.1.3.tgz#528c506238a05dce11cd914a741ea2cc332da9b8" + integrity sha512-mUDCnVNsAi+eD6qA0HkRkwYczbLHJ49z17BGe2PYRhZL4wpZUFZGJHU7/5tmvohoma+Hdn0Vh/oJTiPEmgSruA== + mixin-deep@^1.2.0: version "1.3.1" resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe" @@ -7054,6 +7130,20 @@ qs@6.5.2, qs@~6.5.2: resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== +qs@^6.4.0, qs@^6.5.1: + version "6.7.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" + integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== + +query-string@^6.1.0: + version "6.4.2" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.4.2.tgz#8be1dbd105306aebf86022144f575a29d516b713" + integrity sha512-DfJqAen17LfLA3rQ+H5S4uXphrF+ANU1lT2ijds4V/Tj4gZxA3gx5/tg1bz7kYCmwna7LyJNCYqO7jNRzo3aLw== + dependencies: + decode-uri-component "^0.2.0" + split-on-first "^1.0.0" + strict-uri-encode "^2.0.0" + query-string@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.2.0.tgz#468edeb542b7e0538f9f9b1aeb26f034f19c86e1" @@ -7291,7 +7381,7 @@ read-pkg@^4.0.1: parse-json "^4.0.0" pify "^3.0.0" -"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.2.9, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6: +"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.2.9, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.6" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw== @@ -7943,6 +8033,16 @@ snapdragon@^0.8.1: source-map-resolve "^0.5.0" use "^3.1.0" +sntp@3.x.x: + version "3.0.2" + resolved "https://registry.yarnpkg.com/sntp/-/sntp-3.0.2.tgz#3f0b5de6115681dce82a9478691f0e5c552de5a3" + integrity sha512-MCAPpBPFjNp1fwDVCLSRuWuH9gONtb2R+lS1esC6Mp8lP6jy60FVUtP/Qr0jBvcWAVbhzx06y1b6ptXiy32dug== + dependencies: + boom "7.x.x" + bounce "1.x.x" + hoek "6.x.x" + teamwork "3.x.x" + sockjs-client@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.3.0.tgz#12fc9d6cb663da5739d3dc5fb6e8687da95cb177" @@ -8072,6 +8172,11 @@ spdy@^3.4.1: select-hose "^2.0.0" spdy-transport "^2.0.18" +split-on-first@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-1.0.0.tgz#648af4ce9a28fbcaadd43274455f298b55025fc6" + integrity sha512-mjA57TQtdWztVZ9THAjGNpgbuIrNfsNrGa5IyK94NoPaT4N14M+GI4jD7t4arLjFkYRQWdETC5RxFzLWouoB3A== + split-string@^3.0.1, split-string@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" @@ -8288,6 +8393,22 @@ style-loader@^0.23.1: loader-utils "^1.1.0" schema-utils "^1.0.0" +superagent@^3.8.2: + version "3.8.3" + resolved "https://registry.yarnpkg.com/superagent/-/superagent-3.8.3.tgz#460ea0dbdb7d5b11bc4f78deba565f86a178e128" + integrity sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA== + dependencies: + component-emitter "^1.2.0" + cookiejar "^2.1.0" + debug "^3.1.0" + extend "^3.0.0" + form-data "^2.3.1" + formidable "^1.2.0" + methods "^1.1.1" + mime "^1.4.1" + qs "^6.5.1" + readable-stream "^2.3.5" + supports-color@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" @@ -8350,6 +8471,31 @@ tar@^4: safe-buffer "^5.1.2" yallist "^3.0.2" +taskcluster-client-web@9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/taskcluster-client-web/-/taskcluster-client-web-9.0.0.tgz#f160f264852683e36cc4f7bcaa57895ad91b3f6e" + integrity sha512-cuAk5JLtX9SSngsfUiIARWWYj0uGKt2vB6yqT3w2gSR3mXReLb9AlQHsgpL51VgdpbXw2YMopUV8U8auCmSBmQ== + dependencies: + crypto-js "^3.1.9-1" + hawk "^7.0.7" + query-string "^6.1.0" + taskcluster-lib-urls "^10.0.0" + +taskcluster-lib-urls@^10.0.0: + version "10.1.1" + resolved "https://registry.yarnpkg.com/taskcluster-lib-urls/-/taskcluster-lib-urls-10.1.1.tgz#67d5b9449b947e5234eafdd15c46267dde29bf74" + integrity sha512-tdrK++rCX73FMXk/cXwS6RLTjA3pX8hJlxg1ECLs3L3llCOPMNhQ4wi6lb6yMgHc/s5on/Edj6AlAH7gkxzgPg== + +taskcluster-lib-urls@^12.0.0: + version "12.0.0" + resolved "https://registry.yarnpkg.com/taskcluster-lib-urls/-/taskcluster-lib-urls-12.0.0.tgz#f56190eec9e9597d37a42ad0e7f461e8e0e6732b" + integrity sha512-OrEFE0m3p/+mGsmIwjttLhSKg3io6MpJLhYtPNjVSZA9Ix8Y5tprN3vM6a3MjWt5asPF6AKZsfT43cgpGwJB0g== + +teamwork@3.x.x: + version "3.2.0" + resolved "https://registry.yarnpkg.com/teamwork/-/teamwork-3.2.0.tgz#27916edab815459c1a4686252eb18fb5925f49fa" + integrity sha512-xAmJ8PIVjRZMXAHgUuOP8ITsv0SedyWAit2UWiNImXgg/F+BxrsG46ZegElNBM0Dwp+iMfbigg/Ll/M2oDRYww== + terser-webpack-plugin@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.1.0.tgz#cf7c25a1eee25bf121f4a587bb9e004e3f80e528" @@ -8643,6 +8789,11 @@ urix@^0.1.0: resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= +url-join@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/url-join/-/url-join-1.1.0.tgz#741c6c2f4596c4830d6718460920d0c92202dc78" + integrity sha1-dBxsL0WWxIMNZxhGCSDQySIC3Hg= + url-loader@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-1.1.2.tgz#b971d191b83af693c5e3fea4064be9e1f2d7f8d8" @@ -9002,6 +9153,11 @@ wide-align@^1.1.0: dependencies: string-width "^1.0.2 || 2" +winchan@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/winchan/-/winchan-0.2.1.tgz#19b334e49f7c07c0849f921f405fad87dfc8a1da" + integrity sha512-QrG9q+ObfmZBxScv0HSCqFm/owcgyR5Sgpiy1NlCZPpFXhbsmNHhTiLWoogItdBUi0fnU7Io/5ABEqRta5/6Dw== + wordwrap@~0.0.2: version "0.0.3" resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"