Skip to content

Commit

Permalink
feat: Add Sign-out functionality (#46)
Browse files Browse the repository at this point in the history
#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.
  • Loading branch information
bryphe-coder committed Jan 25, 2022
1 parent a44056c commit 69d88b4
Show file tree
Hide file tree
Showing 20 changed files with 414 additions and 25 deletions.
1 change: 1 addition & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
14 changes: 14 additions & 0 deletions coderd/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 29 additions & 0 deletions coderd/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
})
}
13 changes: 13 additions & 0 deletions codersdk/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions codersdk/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
13 changes: 13 additions & 0 deletions site/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
31 changes: 31 additions & 0 deletions site/components/Icons/Logout.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
1 change: 1 addition & 0 deletions site/components/Icons/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { CoderIcon } from "./CoderIcon"
export { Logo } from "./Logo"
export * from "./Logout"
export { WorkspacesIcon } from "./WorkspacesIcon"
31 changes: 31 additions & 0 deletions site/components/Navbar/BorderedMenu.tsx
Original file line number Diff line number Diff line change
@@ -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)}`,
},
}))
125 changes: 125 additions & 0 deletions site/components/Navbar/UserDropdown.tsx
Original file line number Diff line number Diff line change
@@ -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,
},
}))
23 changes: 21 additions & 2 deletions site/components/Navbar/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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()
})
})
16 changes: 5 additions & 11 deletions site/components/Navbar/index.tsx
Original file line number Diff line number Diff line change
@@ -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}>
Expand All @@ -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>
)
}
Expand Down

0 comments on commit 69d88b4

Please sign in to comment.