diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 20eabbcac5..610cd7f159 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -34,6 +34,7 @@ import { SidebarUserBadge, SidebarThemeToggle, } from '@backstage/core'; +import { NavLink } from 'react-router-dom'; const useSidebarLogoStyles = makeStyles({ root: { @@ -56,9 +57,11 @@ const SidebarLogo: FC<{}> = () => { return (
- - {isOpen ? : } - + + + {isOpen ? : } + +
); }; diff --git a/packages/backend/README.md b/packages/backend/README.md index d1cc8b99b7..061c0d51a5 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -15,6 +15,7 @@ app. Until then, feel free to experiment here! To run the example backend, first go to the project root and run ```bash +yarn tsc yarn install yarn build ``` diff --git a/packages/catalog-model/src/location/annotation.ts b/packages/catalog-model/src/location/annotation.ts new file mode 100644 index 0000000000..ab7a90249a --- /dev/null +++ b/packages/catalog-model/src/location/annotation.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * 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. + */ +export const LOCATION_ANNOTATION = 'backstage.io/managed-by-location'; diff --git a/packages/catalog-model/src/location/index.ts b/packages/catalog-model/src/location/index.ts index 60465bff9b..ff696524ab 100644 --- a/packages/catalog-model/src/location/index.ts +++ b/packages/catalog-model/src/location/index.ts @@ -16,3 +16,4 @@ export type { Location, LocationSpec } from './types'; export { locationSchema, locationSpecSchema } from './validation'; +export { LOCATION_ANNOTATION } from './annotation'; diff --git a/packages/cli/src/lib/tasks.ts b/packages/cli/src/lib/tasks.ts index 2d592bc549..3516c45b9b 100644 --- a/packages/cli/src/lib/tasks.ts +++ b/packages/cli/src/lib/tasks.ts @@ -143,6 +143,7 @@ export async function installWithLocalDeps(dir: string) { // Add to both resolutions and dependencies, or transitive dependencies will still be fetched from the registry. pkgJson.dependencies[`@backstage/${name}`] = `file:${pkgPath}`; pkgJson.resolutions[`@backstage/${name}`] = `file:${pkgPath}`; + delete pkgJson.devDependencies[`@backstage/${name}`]; await fs .writeJSON(pkgJsonPath, pkgJson, { encoding: 'utf8', spaces: 2 }) diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index c066c83b73..4cbc8bcfb1 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -143,6 +143,30 @@ export type OpenIdConnectApi = { logout(): Promise; }; +export type ProfileInfoOptions = { + /** + * If this is set to true, the user will not be prompted to log in, + * and an empty profile will be returned if there is no existing session. + * + * This can be used to perform a check whether the user is logged in, or if you don't + * want to force a user to be logged in, but provide functionality if they already are. + * + * @default false + */ + optional?: boolean; +}; + +export type ProfileInfoApi = { + getProfile(options?: ProfileInfoOptions): Promise; +}; + +export type ProfileInfo = { + provider: string; + email: string; + name?: string; + picture?: string; +}; + /** * Provides authentication towards Google APIs and identities. * @@ -151,7 +175,9 @@ export type OpenIdConnectApi = { * Note that the ID token payload is only guaranteed to contain the user's numerical Google ID, * email and expiration information. Do not rely on any other fields, as they might not be present. */ -export const googleAuthApiRef = createApiRef({ +export const googleAuthApiRef = createApiRef< + OAuthApi & OpenIdConnectApi & ProfileInfoApi +>({ id: 'core.auth.google', description: 'Provides authentication towards Google APIs and identities', }); diff --git a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts index 582afbcfea..d65a2c71ee 100644 --- a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts @@ -22,6 +22,9 @@ import { OpenIdConnectApi, IdTokenOptions, AccessTokenOptions, + ProfileInfoApi, + ProfileInfoOptions, + ProfileInfo, } from '../../../definitions/auth'; import { OAuthRequestApi, AuthProvider } from '../../../definitions'; import { SessionManager } from '../../../../lib/AuthSessionManager/types'; @@ -39,6 +42,7 @@ type CreateOptions = { }; export type GoogleAuthResponse = { + profile: ProfileInfo; accessToken: string; idToken: string; scope: string; @@ -53,7 +57,7 @@ const DEFAULT_PROVIDER = { const SCOPE_PREFIX = 'https://www.googleapis.com/auth/'; -class GoogleAuth implements OAuthApi, OpenIdConnectApi { +class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi { static create({ apiOrigin, basePath, @@ -69,6 +73,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi { oauthRequestApi: oauthRequestApi, sessionTransform(res: GoogleAuthResponse): GoogleSession { return { + profile: res.profile, idToken: res.idToken, accessToken: res.accessToken, scopes: GoogleAuth.normalizeScopes(res.scope), @@ -123,6 +128,14 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi { await this.sessionManager.removeSession(); } + async getProfile(options: ProfileInfoOptions = {}) { + const session = await this.sessionManager.getSession(options); + if (!session) { + return undefined; + } + return session.profile; + } + static normalizeScopes(scopes?: string | string[]): Set { if (!scopes) { return new Set(); diff --git a/packages/core-api/src/apis/implementations/auth/google/types.ts b/packages/core-api/src/apis/implementations/auth/google/types.ts index 96c69c5d4f..ea251c7006 100644 --- a/packages/core-api/src/apis/implementations/auth/google/types.ts +++ b/packages/core-api/src/apis/implementations/auth/google/types.ts @@ -14,7 +14,10 @@ * limitations under the License. */ +import { ProfileInfo } from '../../../definitions'; + export type GoogleSession = { + profile: ProfileInfo; idToken: string; accessToken: string; scopes: Set; diff --git a/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts b/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts index 64df3deca4..3df21af3f3 100644 --- a/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts +++ b/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts @@ -117,6 +117,10 @@ export class RefreshingAuthSessionManager implements SessionManager { window.location.reload(); // TODO(Rugvip): make this work without reload? } + async getCurrentSession() { + return this.currentSession; + } + private async collapsedSessionRefresh(): Promise { if (this.refreshPromise) { return this.refreshPromise; diff --git a/packages/core/src/layout/HeaderTabs/index.tsx b/packages/core/src/layout/HeaderTabs/index.tsx new file mode 100644 index 0000000000..158cd76913 --- /dev/null +++ b/packages/core/src/layout/HeaderTabs/index.tsx @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Spotify AB + * + * 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. + */ + +// TODO(blam): Remove this implementation when the Tabs are ready +// This is just a temporary solution to implementing tabs for now + +import React from 'react'; +import { makeStyles, Tabs, Tab } from '@material-ui/core'; + +const useStyles = makeStyles(theme => ({ + tabsWrapper: { + gridArea: 'pageSubheader', + backgroundColor: theme.palette.background.paper, + }, + defaultTab: { + padding: theme.spacing(3, 3), + ...theme.typography.caption, + textTransform: 'uppercase', + fontWeight: 'bold', + color: theme.palette.text.secondary, + }, + selected: { + color: theme.palette.text.primary, + }, +})); + +export type Tab = { + id: string; + label: string; +}; +export const HeaderTabs: React.FC<{ tabs: Tab[] }> = ({ tabs }) => { + const styles = useStyles(); + + return ( +
+ + {tabs.map((tab, index) => ( + + ))} + +
+ ); +}; diff --git a/packages/core/src/layout/Sidebar/LoggedUserBadge.tsx b/packages/core/src/layout/Sidebar/LoggedUserBadge.tsx index f9bd4ed2ab..d0c3dc62c2 100644 --- a/packages/core/src/layout/Sidebar/LoggedUserBadge.tsx +++ b/packages/core/src/layout/Sidebar/LoggedUserBadge.tsx @@ -14,48 +14,285 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React, { FC, useState, useEffect } from 'react'; import { makeStyles, Theme } from '@material-ui/core/styles'; import { sidebarConfig } from './config'; -import { Avatar, Typography } from '@material-ui/core'; +import { + Avatar, + ListItem, + ListItemAvatar, + ListItemText, + Popover, + List, + ListItemIcon, + ListItemSecondaryAction, + IconButton, + Tooltip, + Typography, +} from '@material-ui/core'; +import { blueGrey } from '@material-ui/core/colors'; +import { useSetState } from 'react-use'; +import { Skeleton } from '@material-ui/lab'; +import { useApi, googleAuthApiRef, ProfileInfo } from '@backstage/core-api'; +import LogoutIcon from '@material-ui/icons/PowerSettingsNew'; +import ControlPointIcon from '@material-ui/icons/ControlPoint'; +import AccountCircleIcon from '@material-ui/icons/AccountCircle'; -const useStyles = makeStyles(() => { +const useStyles = makeStyles(theme => { const { drawerWidthOpen, userBadgeDiameter } = sidebarConfig; return { root: { width: drawerWidthOpen, display: 'flex', alignItems: 'center', - color: '#b5b5b5', paddingLeft: 18, paddingTop: 14, paddingBottom: 14, + color: '#b5b5b5', }, avatar: { width: userBadgeDiameter, height: userBadgeDiameter, marginRight: 8, }, + purple: { + color: theme.palette.getContrastText(blueGrey[500]), + backgroundColor: blueGrey[500], + }, + listItemText: { + overflow: 'hidden', + textOverflow: 'ellipsis', + }, }; }); +const SessionListItem: FC<{ + classes: any; + loading: boolean; + title: string; + icon: any; + user: any; + onSignIn: Function; + onSignOut: Function; +}> = ({ + classes, + loading, + title, + icon, + user, + onSignIn, + onSignOut, + ...props +}) => { + if (loading) { + return ( + + + + + } + secondary={} + /> + + + + + + + ); + } + + // TODO: Not functional yet to sign in from the sidebar + if (!user) { + return ( + + {icon} + + + + onSignIn()}> + + + + + + ); + } + + const { id, avatarUrl, avatarAlt } = user; + + return ( + + + + {avatarAlt && avatarAlt[0].toUpperCase()} + + + + {id} + + } + secondary={title} + /> + + + onSignOut()}> + + + + + + ); +}; + +const useGoogleLoginState = (open: boolean) => { + const googleAuth = useApi(googleAuthApiRef); + const [loading, setLoading] = useState(true); + const [profile, setProfile] = useState(); + + useEffect(() => { + let didCancel = false; + + if (open) { + googleAuth.getProfile().then(_profile => { + if (!didCancel) { + setProfile(_profile); + setLoading(false); + } + }); + } + + return () => { + didCancel = true; + }; + }, [open, googleAuth]); + + if (loading) { + return { loading: true }; + } + return { loading: false, isLoggedIn: !!profile, profile }; +}; + type Props = { - imageUrl: string; - name: string; - hideName?: boolean; + email: string; + imageUrl?: string; + name?: string; + collapsedMode?: boolean; }; export const LoggedUserBadge: FC = ({ imageUrl, name, - hideName = false, + email, + collapsedMode = false, }) => { + const [state, setState] = useSetState({ + open: false, + anchorEl: null, + }); + const googleAuth = useApi(googleAuthApiRef); + const googleLogin = useGoogleLoginState(state.open); + + const handleOpen = (event: { + preventDefault: () => void; + currentTarget: any; + }) => { + // This prevents ghost click. + event.preventDefault(); + setState({ + open: true, + anchorEl: event.currentTarget, + }); + }; + + const handleClose = () => { + setState({ + open: false, + }); + }; + + const handleGoogleSignIn = () => { + googleAuth.getIdToken(); + handleClose(); + }; + + const handleGoogleSignOut = () => { + googleAuth.logout(); + }; + const classes = useStyles(); + const avatarFallback = email.charAt(0).toUpperCase() + email.slice(1); + const emailTrimmed = email.split('@')[0]; + const displayEmail = + emailTrimmed.charAt(0).toUpperCase() + emailTrimmed.slice(1); + const displayName = name ?? displayEmail; return ( -
- - {!hideName && {name}} -
+ <> + + + + {imageUrl ? ( + + ) : ( + + {avatarFallback[0]} + + )} + + {!collapsedMode && ( + + {displayName} + + } + /> + )} + + + + + + + + ); }; diff --git a/packages/core/src/layout/Sidebar/UserBadge.tsx b/packages/core/src/layout/Sidebar/UserBadge.tsx index b030ed2996..d3e02d26cc 100644 --- a/packages/core/src/layout/Sidebar/UserBadge.tsx +++ b/packages/core/src/layout/Sidebar/UserBadge.tsx @@ -14,15 +14,16 @@ * limitations under the License. */ -import React, { FC, useContext } from 'react'; +import React, { FC, useContext, useEffect, useState } from 'react'; import { makeStyles } from '@material-ui/core'; -import People from '@material-ui/icons/People'; +import AccountCircleIcon from '@material-ui/icons/AccountCircle'; import { SidebarContext } from './config'; import { SidebarItem } from './Items'; import { LoggedUserBadge } from './LoggedUserBadge'; import DoubleArrowIcon from '@material-ui/icons/DoubleArrow'; import { BackstageTheme } from '@backstage/theme'; import { SidebarPinStateContext } from './Page'; +import { useApi, googleAuthApiRef, ProfileInfo } from '@backstage/core-api'; const ARROW_BUTTON_SIZE = 20; const useStyles = makeStyles(theme => { @@ -58,18 +59,30 @@ export const SidebarUserBadge: FC<{}> = () => { SidebarPinStateContext, ); const classes = useStyles({ isPinned }); + const googleAuth = useApi(googleAuthApiRef); + const [profile, setProfile] = useState(); + + useEffect(() => { + // TODO(soapraj): How to observe if the user is logged in + // TODO(soapraj): List all the providers supported by the app and let user log in from here + googleAuth.getProfile({ optional: true }).then(googleProfile => { + setProfile(googleProfile); + }); + }, [googleAuth]); - const isUserLoggedIn = false; return (
- {isUserLoggedIn ? ( - + {profile ? ( + <> + + ) : ( - + )} {isOpen && (
- - envelopeToComponent( - val, - findLocationForEntity(val, locations), - ), - )) || - [] - } - loading={loading} - error={error} - actions={actions} - /> + {locations && ( + { + return { + ...entityToComponent(val), + location: findLocationForEntity(val, locations), + }; + })) || + [] + } + loading={loading} + error={error} + actions={actions} + /> + )} diff --git a/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx b/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx index 62f34f1620..b9c89604b0 100644 --- a/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx +++ b/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx @@ -24,13 +24,16 @@ import { useApi, ErrorApi, errorApiRef, + HeaderTabs, } from '@backstage/core'; import ComponentContextMenu from '../ComponentContextMenu/ComponentContextMenu'; import ComponentRemovalDialog from '../ComponentRemovalDialog/ComponentRemovalDialog'; + import { SentryIssuesWidget } from '@backstage/plugin-sentry'; import { Grid } from '@material-ui/core'; import { catalogApiRef } from '../..'; -import { envelopeToComponent } from '../../data/utils'; +import { entityToComponent } from '../../data/utils'; +import { Component } from '../../data/component'; const REDIRECT_DELAY = 1000; @@ -54,18 +57,20 @@ const ComponentPage: FC = ({ match, history }) => { const errorApi = useApi(errorApiRef); const catalogApi = useApi(catalogApiRef); - const catalogRequest = useAsync(() => - catalogApi.getEntityByName(match.params.name), - ); + const { value: component, error, loading } = useAsync(async () => { + const entity = await catalogApi.getEntityByName(match.params.name); + const location = await catalogApi.getLocationByEntity(entity); + return { ...entityToComponent(entity), location }; + }); useEffect(() => { - if (catalogRequest.error) { + if (error) { errorApi.post(new Error('Component not found!')); setTimeout(() => { - history.push('/catalog'); + history.push('/'); }, REDIRECT_DELAY); } - }, [catalogRequest.error, errorApi, history]); + }, [error, errorApi, history]); if (componentName === '') { history.push('/catalog'); @@ -76,17 +81,47 @@ const ComponentPage: FC = ({ match, history }) => { setConfirmationDialogOpen(false); setRemovingPending(true); // await componentFactory.removeComponentByName(componentName); - history.push('/catalog'); + + await catalogApi; + history.push('/'); }; - const component = envelopeToComponent(catalogRequest.value! ?? {}); + // TODO - Replace with proper tabs implementation + const tabs = [ + { + id: 'overview', + label: 'Overview', + }, + { + id: 'ci', + label: 'CI/CD', + }, + { + id: 'tests', + label: 'Tests', + }, + { + id: 'api', + label: 'API', + }, + { + id: 'monitoring', + label: 'Monitoring', + }, + { + id: 'quality', + label: 'Quality', + }, + ]; return ( -
+
- {confirmationDialogOpen && catalogRequest.value && ( + + + {confirmationDialogOpen && component && ( = ({ match, history }) => { diff --git a/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx b/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx index b5a863cef8..7dde95c8f0 100644 --- a/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx +++ b/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx @@ -25,6 +25,10 @@ import { useTheme, } from '@material-ui/core'; import { Component } from '../../data/component'; +import { useAsync } from 'react-use'; +import { useApi } from '@backstage/core'; +import { catalogApiRef } from '../../api/types'; +import { Entity } from '@backstage/catalog-model'; type ComponentRemovalDialogProps = { onConfirm: () => any; @@ -38,18 +42,28 @@ const ComponentRemovalDialog: FC = ({ onClose, component, }) => { + const catalogApi = useApi(catalogApiRef); + const { value } = useAsync(async () => { + let colocatedEntities: Array = []; + const locationId = component.location?.id; + if (locationId) { + colocatedEntities = await catalogApi.getEntitiesByLocationId(locationId); + } + return colocatedEntities; + }); const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const infoMessage = `This action will unregister ${ + value ? value.map(e => e.metadata.name).join(', ') : '' + } from location with target ${component.location?.target}. To undo, + just re-register the component in Backstage.`; return ( Are you sure you want to unregister this component? - - This action will unregister {component.name}. To undo, just - re-register the component in Backstage. - + {infoMessage}