Merge pull request #1215 from spotify/mob/sidebar-auth

List auth providers
This commit is contained in:
Niklas Ek
2020-06-10 10:22:30 +02:00
committed by GitHub
8 changed files with 211 additions and 340 deletions
+4 -2
View File
@@ -31,8 +31,9 @@ import {
SidebarDivider,
SidebarSearchField,
SidebarSpace,
SidebarUserBadge,
SidebarUserSettings,
SidebarThemeToggle,
SidebarPinButton,
} from '@backstage/core';
import { NavLink } from 'react-router-dom';
@@ -90,7 +91,8 @@ const Root: FC<{}> = ({ children }) => (
<SidebarSpace />
<SidebarDivider />
<SidebarThemeToggle />
<SidebarUserBadge />
<SidebarUserSettings />
<SidebarPinButton />
</Sidebar>
{children}
</SidebarPage>
+7 -2
View File
@@ -59,6 +59,7 @@ const useStyles = makeStyles<Theme>(theme => {
fontWeight: 'bold',
whiteSpace: 'nowrap',
lineHeight: 1.0,
flex: '3 1 auto',
},
iconContainer: {
boxSizing: 'border-box',
@@ -84,6 +85,11 @@ const useStyles = makeStyles<Theme>(theme => {
searchContainer: {
width: drawerWidthOpen - iconContainerWidth,
},
secondaryAction: {
width: theme.spacing(6),
textAlign: 'center',
marginRight: theme.spacing(1),
},
selected: {
'&$root': {
borderLeft: `solid ${selectedIndicatorWidth}px #9BF0E1`,
@@ -148,7 +154,6 @@ export const SidebarItem: FC<SidebarItemProps> = ({
</NavLink>
);
}
return (
<NavLink
className={clsx(classes.root, classes.open)}
@@ -166,7 +171,7 @@ export const SidebarItem: FC<SidebarItemProps> = ({
{text}
</Typography>
)}
{children}
<div className={classes.secondaryAction}>{children}</div>
</NavLink>
);
};
@@ -1,298 +0,0 @@
/*
* 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.
*/
import React, { FC, useState, useEffect } from 'react';
import { makeStyles, Theme } from '@material-ui/core/styles';
import { sidebarConfig } from './config';
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<Theme>(theme => {
const { drawerWidthOpen, userBadgeDiameter } = sidebarConfig;
return {
root: {
width: drawerWidthOpen,
display: 'flex',
alignItems: 'center',
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 (
<ListItem {...props}>
<ListItemIcon style={{ marginRight: 0 }}>
<Skeleton variant="circle" width={40} height={40} />
</ListItemIcon>
<ListItemText
primary={<Skeleton component="span" width={120} />}
secondary={<Skeleton component="span" width={60} />}
/>
<ListItemSecondaryAction>
<IconButton>
<Skeleton variant="circle" width={24} height={24} />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
);
}
// TODO: Not functional yet to sign in from the sidebar
if (!user) {
return (
<ListItem {...props}>
<ListItemIcon style={{ marginRight: 0 }}>{icon}</ListItemIcon>
<ListItemText primary="Sign In" secondary={title} />
<ListItemSecondaryAction>
<Tooltip
title={`Sign in with ${title}`}
placement="bottom-end"
PopperProps={{ style: { width: 120 } }}
>
<IconButton onClick={() => onSignIn()}>
<ControlPointIcon />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
}
const { id, avatarUrl, avatarAlt } = user;
return (
<ListItem {...props}>
<ListItemAvatar>
<Avatar src={avatarUrl} alt={avatarAlt}>
{avatarAlt && avatarAlt[0].toUpperCase()}
</Avatar>
</ListItemAvatar>
<ListItemText
className={classes.listItemText}
primary={
<Typography className={classes.listItemText} variant="body2">
{id}
</Typography>
}
secondary={title}
/>
<ListItemSecondaryAction style={{ marginLeft: '30px' }}>
<Tooltip
title={`Sign out from ${title}`}
placement="bottom-end"
PopperProps={{ style: { width: 120 } }}
>
<IconButton onClick={() => onSignOut()}>
<LogoutIcon />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
};
const useGoogleLoginState = (open: boolean) => {
const googleAuth = useApi(googleAuthApiRef);
const [loading, setLoading] = useState(true);
const [profile, setProfile] = useState<ProfileInfo>();
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 = {
email: string;
imageUrl?: string;
name?: string;
collapsedMode?: boolean;
};
export const LoggedUserBadge: FC<Props> = ({
imageUrl,
name,
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 (
<>
<List dense>
<ListItem className={classes.root} onClick={handleOpen}>
<ListItemAvatar>
{imageUrl ? (
<Avatar alt={name} src={imageUrl} className={classes.avatar} />
) : (
<Avatar
alt={name}
className={`${classes.avatar} ${classes.purple}`}
>
{avatarFallback[0]}
</Avatar>
)}
</ListItemAvatar>
{!collapsedMode && (
<ListItemText
primary={
<Typography className={classes.listItemText} variant="body2">
{displayName}
</Typography>
}
/>
)}
</ListItem>
</List>
<Popover
transitionDuration={0}
open={state.open}
anchorEl={state.anchorEl}
anchorOrigin={{ horizontal: 'center', vertical: 'top' }}
transformOrigin={{ horizontal: 'center', vertical: 'bottom' }}
onClose={handleClose}
>
<List dense>
<SessionListItem
classes={classes}
loading={googleLogin.loading}
title="Google"
icon={AccountCircleIcon}
user={
googleLogin.isLoggedIn && {
id: googleLogin.profile?.email,
avatarUrl: googleLogin.profile?.picture ?? '',
avatarAlt:
googleLogin.profile?.picture ?? googleLogin.profile?.email,
}
}
onSignIn={handleGoogleSignIn}
onSignOut={handleGoogleSignOut}
/>
</List>
</Popover>
</>
);
};
@@ -14,35 +14,32 @@
* limitations under the License.
*/
import React, { FC, useContext, useEffect, useState } from 'react';
import React, { FC, useContext } from 'react';
import { makeStyles } from '@material-ui/core';
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 { SidebarContext } from './config';
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<BackstageTheme, { isPinned: boolean }>(theme => {
return {
root: {
position: 'relative',
alignSelf: 'stretch',
},
arrowButtonWrapper: {
position: 'absolute',
right: 0,
width: ARROW_BUTTON_SIZE,
height: ARROW_BUTTON_SIZE,
top: `calc(50% - ${ARROW_BUTTON_SIZE / 2}px)`,
top: -(theme.spacing(6) + ARROW_BUTTON_SIZE) / 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '2px 0px 0px 2px',
background: theme.palette.pinSidebarButton.icon,
color: theme.palette.pinSidebarButton.background,
background: theme.palette.pinSidebarButton.background,
color: theme.palette.pinSidebarButton.icon,
border: 'none',
outline: 'none',
cursor: 'pointer',
@@ -53,37 +50,15 @@ const useStyles = makeStyles<BackstageTheme, { isPinned: boolean }>(theme => {
};
});
export const SidebarUserBadge: FC<{}> = () => {
export const SidebarPinButton: FC<{}> = () => {
const { isOpen } = useContext(SidebarContext);
const { isPinned, toggleSidebarPinState } = useContext(
SidebarPinStateContext,
);
const classes = useStyles({ isPinned });
const googleAuth = useApi(googleAuthApiRef);
const [profile, setProfile] = useState<ProfileInfo>();
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]);
return (
<div className={classes.root}>
{profile ? (
<>
<LoggedUserBadge
email={profile.email}
imageUrl={profile.picture}
name={profile.name}
collapsedMode={!isOpen}
/>
</>
) : (
<SidebarItem icon={AccountCircleIcon} text="" disableSelected />
)}
{isOpen && (
<button
className={classes.arrowButtonWrapper}
@@ -22,7 +22,6 @@ import {
SidebarDivider,
SidebarSearchField,
SidebarSpace,
SidebarUserBadge,
} from '.';
import HomeOutlinedIcon from '@material-ui/icons/HomeOutlined';
import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline';
@@ -55,6 +54,5 @@ export const SampleSidebar = () => (
<SidebarIntro />
<SidebarSpace />
<SidebarDivider />
<SidebarUserBadge />
</Sidebar>
);
@@ -0,0 +1,188 @@
/*
* 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.
*/
import React, { useState, useContext, useEffect, useRef } from 'react';
import Collapse from '@material-ui/core/Collapse';
import ExpandLess from '@material-ui/icons/ExpandLess';
import ExpandMore from '@material-ui/icons/ExpandMore';
import StarBorder from '@material-ui/icons/StarBorder';
import Star from '@material-ui/icons/Star';
import { SidebarContext } from './config';
import { SidebarItem } from './Items';
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
import Divider from '@material-ui/core/Divider';
import {
useApi,
googleAuthApiRef,
githubAuthApiRef,
ProfileInfo,
} from '@backstage/core-api';
import { Avatar, IconButton, makeStyles, Tooltip } from '@material-ui/core';
import PowerButton from '@material-ui/icons/PowerSettingsNew';
type Provider = {
title: string;
api: any;
identity?: boolean;
isSignedIn: boolean;
icon: any;
};
const useProviders = () => {
const googleAuth = useApi(googleAuthApiRef);
const githubAuth = useApi(githubAuthApiRef);
const [providers, setProviders] = useState<Provider[]>([
{
title: 'Google',
api: googleAuth,
identity: true,
isSignedIn: false,
icon: Star,
},
{
title: 'Github',
api: githubAuth,
isSignedIn: false,
icon: StarBorder,
},
]);
const setIsSignedIn = async () => {
const signInChecks = await Promise.all(
providers.map(provider =>
provider.identity
? provider.api.getIdToken({ optional: true })
: provider.api.getAccessToken('', { optional: true }),
),
);
signInChecks.map((result, i) => {
providers[i].isSignedIn = !!result;
});
setProviders(providers);
};
setIsSignedIn();
return providers;
};
const useStyles = makeStyles({
avatar: {
width: 24,
height: 24,
},
});
export function SidebarUserSettings() {
const { isOpen: sidebarOpen } = useContext(SidebarContext);
const [open, setOpen] = React.useState(false);
const ref = useRef<Element>(); // for scrolling down when collapse item opens
const providers = useProviders();
const [profile, setProfile] = useState<ProfileInfo>();
const classes = useStyles();
// TODO(soapraj): List all the providers supported by the app and let user log in from here
// TODO(soapraj): How to observe if the user is logged in
useEffect(() => {
const identityProvider = providers.find(
(provider: Provider) => provider.identity,
);
identityProvider?.api
.getProfile({ optional: true })
.then((userProfile: ProfileInfo) => {
setProfile(userProfile);
});
}, [providers, open]);
const handleClick = () => {
setOpen(!open);
setTimeout(() => ref.current?.scrollIntoView({ behavior: 'smooth' }), 300);
};
// Close the provider list when sidebar collapse
useEffect(() => {
if (!sidebarOpen && open) setOpen(false);
}, [open, sidebarOpen]);
// Handle main auth info that is shown on the collapsible SidebarItem
let avatar;
let displayName;
if (profile) {
const email = profile.email;
const name = profile.name;
const imageUrl = profile.picture;
const avatarFallback = email.charAt(0).toUpperCase() + email.slice(1);
const emailTrimmed = email.split('@')[0];
const displayEmail =
emailTrimmed.charAt(0).toUpperCase() + emailTrimmed.slice(1);
displayName = name ?? displayEmail;
avatar = imageUrl
? () => <Avatar alt={name} src={imageUrl} className={classes.avatar} />
: () => (
<Avatar alt={name} className={classes.avatar}>
{avatarFallback[0]}
</Avatar>
);
}
return (
<>
<Divider innerRef={ref} />
<SidebarItem
text={displayName || 'Guest'}
onClick={handleClick}
icon={avatar || AccountCircleIcon}
disableSelected
>
{open ? <ExpandLess /> : <ExpandMore />}
</SidebarItem>
<Collapse in={open} timeout="auto" unmountOnExit>
{providers.map((provider: Provider) => (
<SidebarItem
key={provider.title}
text={provider.title}
icon={provider.icon ?? StarBorder}
disableSelected
>
<IconButton
onClick={() =>
provider.isSignedIn
? provider.api.logout()
: provider.api.getAccessToken()
}
>
<Tooltip
placement="top"
arrow
title={
provider.isSignedIn
? `Logout from ${provider.title}`
: `Sign in to ${provider.title}`
}
>
<PowerButton
color={provider.isSignedIn ? 'secondary' : 'primary'}
/>
</Tooltip>
</IconButton>
</SidebarItem>
))}
</Collapse>
</>
);
}
+2 -1
View File
@@ -25,7 +25,7 @@ export {
SidebarSpacer,
} from './Items';
export { IntroCard, SidebarIntro } from './Intro';
export { SidebarUserBadge } from './UserBadge';
export { SidebarPinButton } from './PinButton';
export {
SIDEBAR_INTRO_LOCAL_STORAGE,
SidebarContext,
@@ -33,3 +33,4 @@ export {
} from './config';
export type { SidebarContextType } from './config';
export { SidebarThemeToggle } from './SidebarThemeToggle';
export { SidebarUserSettings } from './UserSettings';
+3 -3
View File
@@ -57,8 +57,8 @@ export const lightTheme = createTheme({
gold: yellow.A700,
sidebar: '#171717',
pinSidebarButton: {
icon: '#BDBDBD',
background: '#404040',
icon: '#181818',
background: '#BDBDBD',
},
tabbar: {
indicator: '#9BF0E1',
@@ -106,7 +106,7 @@ export const darkTheme = createTheme({
gold: yellow.A700,
sidebar: '#424242',
pinSidebarButton: {
icon: '#181818',
icon: '#404040',
background: '#BDBDBD',
},
tabbar: {