feat: Add Sign-out functionality (#46)

#37 implemented the Sign-_in_ flow, but there wasn't a Sign-_out_ flow as part of that PR (aside from letting the cookie expire... or manually deleting the cookie...), which is obviously not ideal.

This PR implements a basic sign-out flow, along with a very simple user dropdown:
![2022-01-21 18 09 14](https://user-images.githubusercontent.com/88213859/150620847-94e4d22f-1dcf-451e-8b4a-cec24702ea6c.gif)

Bringing in a few pruned down components for the `<UserDropdown />` to integrate into the `<NavBar />`.

In addition, this also implements a simple back-end API for `/logout` which just clears the session token.
This commit is contained in:
Bryan 2022-01-24 17:09:39 -08:00 committed by GitHub
parent a44056cff5
commit 69d88b4a6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 414 additions and 25 deletions

View File

@ -35,6 +35,7 @@ func New(options *Options) http.Handler {
})
})
r.Post("/login", users.loginWithPassword)
r.Post("/logout", users.logout)
r.Route("/users", func(r chi.Router) {
r.Post("/", users.createInitialUser)

View File

@ -237,6 +237,20 @@ func (users *users) loginWithPassword(rw http.ResponseWriter, r *http.Request) {
})
}
// Clear the user's session cookie
func (*users) logout(rw http.ResponseWriter, r *http.Request) {
// Get a blank token cookie
cookie := &http.Cookie{
// MaxAge < 0 means to delete the cookie now
MaxAge: -1,
Name: httpmw.AuthCookie,
Path: "/",
}
http.SetCookie(rw, cookie)
render.Status(r, http.StatusOK)
}
// Generates a new ID and secret for an API key.
func generateAPIKeyIDSecret() (id string, secret string, err error) {
// Length of an API Key ID.

View File

@ -2,12 +2,14 @@ package coderd_test
import (
"context"
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/httpmw"
)
func TestUsers(t *testing.T) {
@ -75,3 +77,30 @@ func TestUsers(t *testing.T) {
require.Len(t, orgs, 1)
})
}
func TestLogout(t *testing.T) {
t.Parallel()
t.Run("LogoutShouldClearCookie", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
fullURL, err := server.URL.Parse("/api/v2/logout")
require.NoError(t, err, "Server URL should parse successfully")
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, fullURL.String(), nil)
require.NoError(t, err, "/logout request construction should succeed")
httpClient := &http.Client{}
response, err := httpClient.Do(req)
require.NoError(t, err, "/logout request should succeed")
response.Body.Close()
cookies := response.Cookies()
require.Len(t, cookies, 1, "Exactly one cookie should be returned")
require.Equal(t, cookies[0].Name, httpmw.AuthCookie, "Cookie should be the auth cookie")
require.Equal(t, cookies[0].MaxAge, -1, "Cookie should be set to delete")
})
}

View File

@ -44,6 +44,19 @@ func (c *Client) LoginWithPassword(ctx context.Context, req coderd.LoginWithPass
return resp, nil
}
// Logout calls the /logout API
// Call `ClearSessionToken()` to clear the session token of the client.
func (c *Client) Logout(ctx context.Context) error {
// Since `LoginWithPassword` doesn't actually set a SessionToken
// (it requires a call to SetSessionToken), this is essentially a no-op
res, err := c.request(ctx, http.MethodPost, "/api/v2/logout", nil)
if err != nil {
return err
}
defer res.Body.Close()
return nil
}
// User returns a user for the ID provided.
// If the ID string is empty, the current user will be returned.
func (c *Client) User(ctx context.Context, id string) (coderd.User, error) {

View File

@ -47,4 +47,12 @@ func TestUsers(t *testing.T) {
require.NoError(t, err)
require.Len(t, orgs, 1)
})
t.Run("LogoutIsSuccessful", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
_ = server.RandomInitialUser(t)
err := server.Client.Logout(context.Background())
require.NoError(t, err)
})
}

View File

@ -21,3 +21,16 @@ export const login = async (email: string, password: string): Promise<LoginRespo
return body
}
export const logout = async (): Promise<void> => {
const response = await fetch("/api/v2/logout", {
method: "POST",
})
if (!response.ok) {
const body = await response.json()
throw new Error(body.message)
}
return
}

View File

@ -0,0 +1,31 @@
import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon"
import React from "react"
export const LogoutIcon = (props: SvgIconProps): JSX.Element => (
<SvgIcon {...props} viewBox="0 0 20 20">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.92523 18.5071H11.2169C11.8878 18.5063 12.4314 17.9626 12.4322 17.2918V15.4689H11.2169V17.2918H3.92523V2.70844H11.2169V4.53136H12.4322V2.70844V2.70845C12.4314 2.03759 11.8878 1.49394 11.2169 1.49316H3.92524C3.25438 1.49393 2.71073 2.03758 2.70996 2.70844V17.2918V17.2918C2.71073 17.9626 3.25438 18.5063 3.92523 18.5071Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.6751 17.292C12.6742 18.0968 12.022 18.7491 11.2171 18.75H3.92513V18.507L11.2168 18.5072C11.8877 18.5064 12.4313 17.9625 12.4321 17.2917V15.4688H11.2168V17.2917H3.92513V2.70834H11.2168V4.53125H12.4321V2.70848C12.4313 2.03762 11.8877 1.49383 11.2168 1.49306H3.92513V1.25H11.2168C12.0217 1.25093 12.6742 1.90319 12.6751 2.70806V4.77431H10.9737V2.95139H4.16818V17.0486H10.9737V15.2257H12.6751V17.292ZM2.70985 2.70833C2.71062 2.03747 3.25427 1.49383 3.92513 1.49306V1.25C3.12025 1.25092 2.46772 1.90318 2.4668 2.70805V17.2917C2.46772 18.0965 3.12025 18.7491 3.92513 18.75V18.507C3.25427 18.5062 2.71062 17.9624 2.70985 17.2915V2.70833Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.7879 12.7867L14.9669 10.6077H6.35547V9.39244H14.9669L12.7879 7.21345L13.6471 6.35425L17.293 10.0001L13.6471 13.6459L12.7879 12.7867Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.4446 12.7867L14.3805 10.8508H6.11279V9.14937H14.3805L12.4446 7.21343L13.6475 6.0105L17.6371 10.0001L13.6475 13.9896L12.4446 12.7867ZM14.9673 9.39243H6.35585V10.6077H14.9673L12.7883 12.7867L13.6475 13.6459L17.2934 10.0001L13.6475 6.35423L12.7883 7.21343L14.9673 9.39243Z"
fill="currentColor"
/>
</SvgIcon>
)

View File

@ -1,3 +1,4 @@
export { CoderIcon } from "./CoderIcon"
export { Logo } from "./Logo"
export * from "./Logout"
export { WorkspacesIcon } from "./WorkspacesIcon"

View File

@ -0,0 +1,31 @@
import Popover, { PopoverProps } from "@material-ui/core/Popover"
import { fade, makeStyles } from "@material-ui/core/styles"
import React from "react"
type BorderedMenuVariant = "manage-dropdown" | "user-dropdown"
type BorderedMenuProps = Omit<PopoverProps, "variant"> & {
variant?: BorderedMenuVariant
}
export const BorderedMenu: React.FC<BorderedMenuProps> = ({ children, variant, ...rest }) => {
const styles = useStyles()
return (
<Popover classes={{ root: styles.root, paper: styles.paperRoot }} data-variant={variant} {...rest}>
{children}
</Popover>
)
}
const useStyles = makeStyles((theme) => ({
root: {
paddingBottom: theme.spacing(1),
},
paperRoot: {
width: "292px",
border: `2px solid ${theme.palette.primary.main}`,
borderRadius: 7,
boxShadow: `4px 4px 0px ${fade(theme.palette.primary.main, 0.2)}`,
},
}))

View File

@ -0,0 +1,125 @@
import Badge from "@material-ui/core/Badge"
import Divider from "@material-ui/core/Divider"
import ListItemIcon from "@material-ui/core/ListItemIcon"
import ListItemText from "@material-ui/core/ListItemText"
import MenuItem from "@material-ui/core/MenuItem"
import { fade, makeStyles } from "@material-ui/core/styles"
import KeyboardArrowDown from "@material-ui/icons/KeyboardArrowDown"
import KeyboardArrowUp from "@material-ui/icons/KeyboardArrowUp"
import React, { useState } from "react"
import { LogoutIcon } from "../Icons"
import { BorderedMenu } from "./BorderedMenu"
import { UserProfileCard } from "../User/UserProfileCard"
import { User } from "../../contexts/UserContext"
import { UserAvatar } from "../User"
export interface UserDropdownProps {
user: User
onSignOut: () => void
}
export const UserDropdown: React.FC<UserDropdownProps> = ({ user, onSignOut }: UserDropdownProps) => {
const styles = useStyles()
const [anchorEl, setAnchorEl] = useState<HTMLElement | undefined>()
const handleDropdownClick = (ev: React.MouseEvent<HTMLLIElement>): void => {
setAnchorEl(ev.currentTarget)
}
const onPopoverClose = () => {
setAnchorEl(undefined)
}
return (
<>
<div>
<MenuItem onClick={handleDropdownClick}>
<div className={styles.inner}>
{user && (
<Badge overlap="circle">
<UserAvatar user={user} />
</Badge>
)}
{anchorEl ? (
<KeyboardArrowUp className={`${styles.arrowIcon} ${styles.arrowIconUp}`} />
) : (
<KeyboardArrowDown className={styles.arrowIcon} />
)}
</div>
</MenuItem>
</div>
<BorderedMenu
anchorEl={anchorEl}
getContentAnchorEl={null}
open={!!anchorEl}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
marginThreshold={0}
variant="user-dropdown"
onClose={onPopoverClose}
>
{user && (
<div className={styles.userInfo}>
<UserProfileCard user={user} />
<Divider className={styles.divider} />
<MenuItem className={styles.menuItem} onClick={onSignOut}>
<ListItemIcon className={styles.icon}>
<LogoutIcon />
</ListItemIcon>
<ListItemText primary="Sign Out" />
</MenuItem>
</div>
)}
</BorderedMenu>
</>
)
}
export const useStyles = makeStyles((theme) => ({
divider: {
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
},
inner: {
display: "flex",
alignItems: "center",
minWidth: 0,
maxWidth: 300,
},
userInfo: {
marginBottom: theme.spacing(1),
},
arrowIcon: {
color: fade(theme.palette.primary.contrastText, 0.7),
marginLeft: theme.spacing(1),
width: 16,
height: 16,
},
arrowIconUp: {
color: theme.palette.primary.contrastText,
},
menuItem: {
height: 44,
padding: `${theme.spacing(1.5)}px ${theme.spacing(2.75)}px`,
"&:hover": {
backgroundColor: fade(theme.palette.primary.light, 0.1),
transition: "background-color 0.3s ease",
},
},
icon: {
color: theme.palette.text.secondary,
},
}))

View File

@ -1,15 +1,34 @@
import React from "react"
import { screen } from "@testing-library/react"
import { render } from "../../test_helpers"
import { render, MockUser } from "../../test_helpers"
import { Navbar } from "./index"
describe("Navbar", () => {
const noop = () => {
return
}
it("renders content", async () => {
// When
render(<Navbar />)
render(<Navbar onSignOut={noop} />)
// Then
await screen.findAllByText("Coder", { exact: false })
})
it("renders profile picture for user", async () => {
// Given
const mockUser = {
...MockUser,
username: "bryan",
}
// When
render(<Navbar user={mockUser} onSignOut={noop} />)
// Then
// There should be a 'B' avatar!
const element = await screen.findByText("B")
expect(element).toBeDefined()
})
})

View File

@ -1,18 +1,18 @@
import React from "react"
import Button from "@material-ui/core/Button"
import List from "@material-ui/core/List"
import ListSubheader from "@material-ui/core/ListSubheader"
import { makeStyles } from "@material-ui/core/styles"
import Link from "next/link"
import { User } from "../../contexts/UserContext"
import { Logo } from "../Icons"
import { UserDropdown } from "./UserDropdown"
export interface NavbarProps {
user?: User
onSignOut: () => void
}
export const Navbar: React.FC<NavbarProps> = () => {
export const Navbar: React.FC<NavbarProps> = ({ user, onSignOut }) => {
const styles = useStyles()
return (
<div className={styles.root}>
@ -23,14 +23,8 @@ export const Navbar: React.FC<NavbarProps> = () => {
</Button>
</Link>
</div>
<div className={styles.fullWidth}>
<div className={styles.title}>Coder v2</div>
</div>
<div className={styles.fixed}>
<List>
<ListSubheader>Manage</ListSubheader>
</List>
</div>
<div className={styles.fullWidth} />
<div className={styles.fixed}>{user && <UserDropdown user={user} onSignOut={onSignOut} />}</div>
</div>
)
}

View File

@ -0,0 +1,25 @@
import Avatar from "@material-ui/core/Avatar"
import React from "react"
import { User } from "../../contexts/UserContext"
export interface UserAvatarProps {
user: User
className?: string
}
export const UserAvatar: React.FC<UserAvatarProps> = ({ user, className }) => {
return <Avatar className={className}>{firstLetter(user.username)}</Avatar>
}
/**
* `firstLetter` extracts the first character and returns it, uppercased
*
* If the string is empty or null, returns an empty string
*/
export const firstLetter = (str: string): string => {
if (str && str.length > 0) {
return str[0].toLocaleUpperCase()
}
return ""
}

View File

@ -0,0 +1,58 @@
import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography"
import React from "react"
import { User } from "../../contexts/UserContext"
import { UserAvatar } from "./UserAvatar"
interface UserProfileCardProps {
user: User
}
export const UserProfileCard: React.FC<UserProfileCardProps> = ({ user }) => {
const styles = useStyles()
return (
<div className={styles.root}>
<div className={styles.avatarContainer}>
<UserAvatar className={styles.avatar} user={user} />
</div>
<Typography className={styles.userName}>{user.username}</Typography>
<Typography className={styles.userEmail}>{user.email}</Typography>
</div>
)
}
const useStyles = makeStyles((theme) => ({
root: {
paddingTop: theme.spacing(3),
textAlign: "center",
},
avatarContainer: {
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
avatar: {
width: 48,
height: 48,
borderRadius: "50%",
marginBottom: theme.spacing(1),
transition: `transform .2s`,
"&:hover": {
transform: `scale(1.1)`,
},
},
userName: {
fontSize: 16,
marginBottom: theme.spacing(0.5),
},
userEmail: {
fontSize: 14,
letterSpacing: 0.2,
color: theme.palette.text.secondary,
marginBottom: theme.spacing(1.5),
},
}))

View File

@ -0,0 +1,2 @@
export * from "./UserAvatar"
export * from "./UserProfileCard"

View File

@ -5,6 +5,7 @@ import { SWRConfig } from "swr"
import { render, screen, waitFor } from "@testing-library/react"
import { User, UserProvider, useUser } from "./UserContext"
import { MockUser } from "../test_helpers"
namespace Helpers {
// Helper component that renders out the state of the `useUser` hook.
@ -45,18 +46,11 @@ namespace Helpers {
</SWRConfig>
)
}
export const mockUser: User = {
id: "test-user-id",
username: "TestUser",
email: "test@coder.com",
created_at: "",
}
}
describe("UserContext", () => {
const failingRequest = () => Promise.reject("Failed to load user")
const successfulRequest = () => Promise.resolve(Helpers.mockUser)
const successfulRequest = () => Promise.resolve(MockUser)
// Reset the router to '/' before every test
beforeEach(() => {

View File

@ -2,6 +2,8 @@ import { useRouter } from "next/router"
import React, { useContext, useEffect } from "react"
import useSWR from "swr"
import * as API from "../api"
export interface User {
readonly id: string
readonly username: string
@ -12,9 +14,14 @@ export interface User {
export interface UserContext {
readonly error?: Error
readonly me?: User
readonly signOut: () => Promise<void>
}
const UserContext = React.createContext<UserContext>({})
const UserContext = React.createContext<UserContext>({
signOut: () => {
return Promise.reject("Sign out API not available")
},
})
export const useUser = (redirectOnError = false): UserContext => {
const ctx = useContext(UserContext)
@ -38,13 +45,27 @@ export const useUser = (redirectOnError = false): UserContext => {
}
export const UserProvider: React.FC = (props) => {
const { data, error } = useSWR("/api/v2/users/me")
const router = useRouter()
const { data, error, mutate } = useSWR("/api/v2/users/me")
const signOut = async () => {
await API.logout()
// Tell SWR to invalidate the cache for the user endpoint
await mutate("/api/v2/users/me")
await router.push({
pathname: "/login",
query: {
redirect: router.asPath,
},
})
}
return (
<UserContext.Provider
value={{
error: error,
me: data,
signOut: signOut,
}}
>
{props.children}

View File

@ -12,7 +12,7 @@ import { FullScreenLoader } from "../components/Loader/FullScreenLoader"
const WorkspacesPage: React.FC = () => {
const styles = useStyles()
const { me } = useUser(true)
const { me, signOut } = useUser(true)
if (!me) {
return <FullScreenLoader />
@ -29,7 +29,7 @@ const WorkspacesPage: React.FC = () => {
return (
<div className={styles.root}>
<Navbar user={me} />
<Navbar user={me} onSignOut={signOut} />
<div className={styles.header}>
<SplitButton<string>
color="primary"

View File

@ -11,3 +11,5 @@ export const WrapperComponent: React.FC = ({ children }) => {
export const render = (component: React.ReactElement): RenderResult => {
return wrappedRender(<WrapperComponent>{component}</WrapperComponent>)
}
export * from "./user"

View File

@ -0,0 +1,8 @@
import { User } from "../contexts/UserContext"
export const MockUser: User = {
id: "test-user-id",
username: "TestUser",
email: "test@coder.com",
created_at: "",
}