Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading