Skip to content

Commit 69d88b4

Browse files
authored
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.
1 parent a44056c commit 69d88b4

File tree

20 files changed

+414
-25
lines changed

20 files changed

+414
-25
lines changed

coderd/coderd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ func New(options *Options) http.Handler {
3535
})
3636
})
3737
r.Post("/login", users.loginWithPassword)
38+
r.Post("/logout", users.logout)
3839
r.Route("/users", func(r chi.Router) {
3940
r.Post("/", users.createInitialUser)
4041

coderd/users.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,20 @@ func (users *users) loginWithPassword(rw http.ResponseWriter, r *http.Request) {
237237
})
238238
}
239239

240+
// Clear the user's session cookie
241+
func (*users) logout(rw http.ResponseWriter, r *http.Request) {
242+
// Get a blank token cookie
243+
cookie := &http.Cookie{
244+
// MaxAge < 0 means to delete the cookie now
245+
MaxAge: -1,
246+
Name: httpmw.AuthCookie,
247+
Path: "/",
248+
}
249+
250+
http.SetCookie(rw, cookie)
251+
render.Status(r, http.StatusOK)
252+
}
253+
240254
// Generates a new ID and secret for an API key.
241255
func generateAPIKeyIDSecret() (id string, secret string, err error) {
242256
// Length of an API Key ID.

coderd/users_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ package coderd_test
22

33
import (
44
"context"
5+
"net/http"
56
"testing"
67

78
"github.com/stretchr/testify/require"
89

910
"github.com/coder/coder/coderd"
1011
"github.com/coder/coder/coderd/coderdtest"
12+
"github.com/coder/coder/httpmw"
1113
)
1214

1315
func TestUsers(t *testing.T) {
@@ -75,3 +77,30 @@ func TestUsers(t *testing.T) {
7577
require.Len(t, orgs, 1)
7678
})
7779
}
80+
81+
func TestLogout(t *testing.T) {
82+
t.Parallel()
83+
84+
t.Run("LogoutShouldClearCookie", func(t *testing.T) {
85+
t.Parallel()
86+
87+
server := coderdtest.New(t)
88+
fullURL, err := server.URL.Parse("/api/v2/logout")
89+
require.NoError(t, err, "Server URL should parse successfully")
90+
91+
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, fullURL.String(), nil)
92+
require.NoError(t, err, "/logout request construction should succeed")
93+
94+
httpClient := &http.Client{}
95+
96+
response, err := httpClient.Do(req)
97+
require.NoError(t, err, "/logout request should succeed")
98+
response.Body.Close()
99+
100+
cookies := response.Cookies()
101+
require.Len(t, cookies, 1, "Exactly one cookie should be returned")
102+
103+
require.Equal(t, cookies[0].Name, httpmw.AuthCookie, "Cookie should be the auth cookie")
104+
require.Equal(t, cookies[0].MaxAge, -1, "Cookie should be set to delete")
105+
})
106+
}

codersdk/users.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,19 @@ func (c *Client) LoginWithPassword(ctx context.Context, req coderd.LoginWithPass
4444
return resp, nil
4545
}
4646

47+
// Logout calls the /logout API
48+
// Call `ClearSessionToken()` to clear the session token of the client.
49+
func (c *Client) Logout(ctx context.Context) error {
50+
// Since `LoginWithPassword` doesn't actually set a SessionToken
51+
// (it requires a call to SetSessionToken), this is essentially a no-op
52+
res, err := c.request(ctx, http.MethodPost, "/api/v2/logout", nil)
53+
if err != nil {
54+
return err
55+
}
56+
defer res.Body.Close()
57+
return nil
58+
}
59+
4760
// User returns a user for the ID provided.
4861
// If the ID string is empty, the current user will be returned.
4962
func (c *Client) User(ctx context.Context, id string) (coderd.User, error) {

codersdk/users_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,12 @@ func TestUsers(t *testing.T) {
4747
require.NoError(t, err)
4848
require.Len(t, orgs, 1)
4949
})
50+
51+
t.Run("LogoutIsSuccessful", func(t *testing.T) {
52+
t.Parallel()
53+
server := coderdtest.New(t)
54+
_ = server.RandomInitialUser(t)
55+
err := server.Client.Logout(context.Background())
56+
require.NoError(t, err)
57+
})
5058
}

site/api.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,16 @@ export const login = async (email: string, password: string): Promise<LoginRespo
2121

2222
return body
2323
}
24+
25+
export const logout = async (): Promise<void> => {
26+
const response = await fetch("/api/v2/logout", {
27+
method: "POST",
28+
})
29+
30+
if (!response.ok) {
31+
const body = await response.json()
32+
throw new Error(body.message)
33+
}
34+
35+
return
36+
}

site/components/Icons/Logout.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon"
2+
import React from "react"
3+
4+
export const LogoutIcon = (props: SvgIconProps): JSX.Element => (
5+
<SvgIcon {...props} viewBox="0 0 20 20">
6+
<path
7+
fillRule="evenodd"
8+
clipRule="evenodd"
9+
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"
10+
fill="currentColor"
11+
/>
12+
<path
13+
fillRule="evenodd"
14+
clipRule="evenodd"
15+
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"
16+
fill="currentColor"
17+
/>
18+
<path
19+
fillRule="evenodd"
20+
clipRule="evenodd"
21+
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"
22+
fill="currentColor"
23+
/>
24+
<path
25+
fillRule="evenodd"
26+
clipRule="evenodd"
27+
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"
28+
fill="currentColor"
29+
/>
30+
</SvgIcon>
31+
)

site/components/Icons/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { CoderIcon } from "./CoderIcon"
22
export { Logo } from "./Logo"
3+
export * from "./Logout"
34
export { WorkspacesIcon } from "./WorkspacesIcon"
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import Popover, { PopoverProps } from "@material-ui/core/Popover"
2+
import { fade, makeStyles } from "@material-ui/core/styles"
3+
import React from "react"
4+
5+
type BorderedMenuVariant = "manage-dropdown" | "user-dropdown"
6+
7+
type BorderedMenuProps = Omit<PopoverProps, "variant"> & {
8+
variant?: BorderedMenuVariant
9+
}
10+
11+
export const BorderedMenu: React.FC<BorderedMenuProps> = ({ children, variant, ...rest }) => {
12+
const styles = useStyles()
13+
14+
return (
15+
<Popover classes={{ root: styles.root, paper: styles.paperRoot }} data-variant={variant} {...rest}>
16+
{children}
17+
</Popover>
18+
)
19+
}
20+
21+
const useStyles = makeStyles((theme) => ({
22+
root: {
23+
paddingBottom: theme.spacing(1),
24+
},
25+
paperRoot: {
26+
width: "292px",
27+
border: `2px solid ${theme.palette.primary.main}`,
28+
borderRadius: 7,
29+
boxShadow: `4px 4px 0px ${fade(theme.palette.primary.main, 0.2)}`,
30+
},
31+
}))
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import Badge from "@material-ui/core/Badge"
2+
import Divider from "@material-ui/core/Divider"
3+
import ListItemIcon from "@material-ui/core/ListItemIcon"
4+
import ListItemText from "@material-ui/core/ListItemText"
5+
import MenuItem from "@material-ui/core/MenuItem"
6+
import { fade, makeStyles } from "@material-ui/core/styles"
7+
import KeyboardArrowDown from "@material-ui/icons/KeyboardArrowDown"
8+
import KeyboardArrowUp from "@material-ui/icons/KeyboardArrowUp"
9+
import React, { useState } from "react"
10+
import { LogoutIcon } from "../Icons"
11+
import { BorderedMenu } from "./BorderedMenu"
12+
import { UserProfileCard } from "../User/UserProfileCard"
13+
14+
import { User } from "../../contexts/UserContext"
15+
import { UserAvatar } from "../User"
16+
17+
export interface UserDropdownProps {
18+
user: User
19+
onSignOut: () => void
20+
}
21+
22+
export const UserDropdown: React.FC<UserDropdownProps> = ({ user, onSignOut }: UserDropdownProps) => {
23+
const styles = useStyles()
24+
25+
const [anchorEl, setAnchorEl] = useState<HTMLElement | undefined>()
26+
const handleDropdownClick = (ev: React.MouseEvent<HTMLLIElement>): void => {
27+
setAnchorEl(ev.currentTarget)
28+
}
29+
const onPopoverClose = () => {
30+
setAnchorEl(undefined)
31+
}
32+
33+
return (
34+
<>
35+
<div>
36+
<MenuItem onClick={handleDropdownClick}>
37+
<div className={styles.inner}>
38+
{user && (
39+
<Badge overlap="circle">
40+
<UserAvatar user={user} />
41+
</Badge>
42+
)}
43+
{anchorEl ? (
44+
<KeyboardArrowUp className={`${styles.arrowIcon} ${styles.arrowIconUp}`} />
45+
) : (
46+
<KeyboardArrowDown className={styles.arrowIcon} />
47+
)}
48+
</div>
49+
</MenuItem>
50+
</div>
51+
52+
<BorderedMenu
53+
anchorEl={anchorEl}
54+
getContentAnchorEl={null}
55+
open={!!anchorEl}
56+
anchorOrigin={{
57+
vertical: "bottom",
58+
horizontal: "right",
59+
}}
60+
transformOrigin={{
61+
vertical: "top",
62+
horizontal: "right",
63+
}}
64+
marginThreshold={0}
65+
variant="user-dropdown"
66+
onClose={onPopoverClose}
67+
>
68+
{user && (
69+
<div className={styles.userInfo}>
70+
<UserProfileCard user={user} />
71+
72+
<Divider className={styles.divider} />
73+
74+
<MenuItem className={styles.menuItem} onClick={onSignOut}>
75+
<ListItemIcon className={styles.icon}>
76+
<LogoutIcon />
77+
</ListItemIcon>
78+
<ListItemText primary="Sign Out" />
79+
</MenuItem>
80+
</div>
81+
)}
82+
</BorderedMenu>
83+
</>
84+
)
85+
}
86+
87+
export const useStyles = makeStyles((theme) => ({
88+
divider: {
89+
marginTop: theme.spacing(1),
90+
marginBottom: theme.spacing(1),
91+
},
92+
inner: {
93+
display: "flex",
94+
alignItems: "center",
95+
minWidth: 0,
96+
maxWidth: 300,
97+
},
98+
99+
userInfo: {
100+
marginBottom: theme.spacing(1),
101+
},
102+
arrowIcon: {
103+
color: fade(theme.palette.primary.contrastText, 0.7),
104+
marginLeft: theme.spacing(1),
105+
width: 16,
106+
height: 16,
107+
},
108+
arrowIconUp: {
109+
color: theme.palette.primary.contrastText,
110+
},
111+
112+
menuItem: {
113+
height: 44,
114+
padding: `${theme.spacing(1.5)}px ${theme.spacing(2.75)}px`,
115+
116+
"&:hover": {
117+
backgroundColor: fade(theme.palette.primary.light, 0.1),
118+
transition: "background-color 0.3s ease",
119+
},
120+
},
121+
122+
icon: {
123+
color: theme.palette.text.secondary,
124+
},
125+
}))

0 commit comments

Comments
 (0)