diff --git a/coderd/coderd.go b/coderd/coderd.go index b666df98107c4..2dcf847033abd 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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) diff --git a/coderd/users.go b/coderd/users.go index bac130a53f801..133b8ddc7b557 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -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. diff --git a/coderd/users_test.go b/coderd/users_test.go index 59602f2592432..29a7098eefc1e 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -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") + }) +} diff --git a/codersdk/users.go b/codersdk/users.go index abe62107b90f6..f47e7383f60af 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -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) { diff --git a/codersdk/users_test.go b/codersdk/users_test.go index ee59e97330bd5..a42aa630c0353 100644 --- a/codersdk/users_test.go +++ b/codersdk/users_test.go @@ -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) + }) } diff --git a/site/api.ts b/site/api.ts index 82d3303d45988..aece323b14aaf 100644 --- a/site/api.ts +++ b/site/api.ts @@ -21,3 +21,16 @@ export const login = async (email: string, password: string): Promise => { + const response = await fetch("/api/v2/logout", { + method: "POST", + }) + + if (!response.ok) { + const body = await response.json() + throw new Error(body.message) + } + + return +} diff --git a/site/components/Icons/Logout.tsx b/site/components/Icons/Logout.tsx new file mode 100644 index 0000000000000..d4024e73b07c1 --- /dev/null +++ b/site/components/Icons/Logout.tsx @@ -0,0 +1,31 @@ +import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon" +import React from "react" + +export const LogoutIcon = (props: SvgIconProps): JSX.Element => ( + + + + + + +) diff --git a/site/components/Icons/index.ts b/site/components/Icons/index.ts index c135b8db26e79..17f48ebaa4a98 100644 --- a/site/components/Icons/index.ts +++ b/site/components/Icons/index.ts @@ -1,3 +1,4 @@ export { CoderIcon } from "./CoderIcon" export { Logo } from "./Logo" +export * from "./Logout" export { WorkspacesIcon } from "./WorkspacesIcon" diff --git a/site/components/Navbar/BorderedMenu.tsx b/site/components/Navbar/BorderedMenu.tsx new file mode 100644 index 0000000000000..1228d530673a7 --- /dev/null +++ b/site/components/Navbar/BorderedMenu.tsx @@ -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 & { + variant?: BorderedMenuVariant +} + +export const BorderedMenu: React.FC = ({ children, variant, ...rest }) => { + const styles = useStyles() + + return ( + + {children} + + ) +} + +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)}`, + }, +})) diff --git a/site/components/Navbar/UserDropdown.tsx b/site/components/Navbar/UserDropdown.tsx new file mode 100644 index 0000000000000..be002c4741f15 --- /dev/null +++ b/site/components/Navbar/UserDropdown.tsx @@ -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 = ({ user, onSignOut }: UserDropdownProps) => { + const styles = useStyles() + + const [anchorEl, setAnchorEl] = useState() + const handleDropdownClick = (ev: React.MouseEvent): void => { + setAnchorEl(ev.currentTarget) + } + const onPopoverClose = () => { + setAnchorEl(undefined) + } + + return ( + <> +
+ +
+ {user && ( + + + + )} + {anchorEl ? ( + + ) : ( + + )} +
+
+
+ + + {user && ( +
+ + + + + + + + + + +
+ )} +
+ + ) +} + +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, + }, +})) diff --git a/site/components/Navbar/index.test.tsx b/site/components/Navbar/index.test.tsx index 9a729fd86e8f8..a4d794cf7847d 100644 --- a/site/components/Navbar/index.test.tsx +++ b/site/components/Navbar/index.test.tsx @@ -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() + render() // Then await screen.findAllByText("Coder", { exact: false }) }) + + it("renders profile picture for user", async () => { + // Given + const mockUser = { + ...MockUser, + username: "bryan", + } + + // When + render() + + // Then + // There should be a 'B' avatar! + const element = await screen.findByText("B") + expect(element).toBeDefined() + }) }) diff --git a/site/components/Navbar/index.tsx b/site/components/Navbar/index.tsx index b9fd1e3d95324..01100e40eb842 100644 --- a/site/components/Navbar/index.tsx +++ b/site/components/Navbar/index.tsx @@ -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 = () => { +export const Navbar: React.FC = ({ user, onSignOut }) => { const styles = useStyles() return (
@@ -23,14 +23,8 @@ export const Navbar: React.FC = () => {
-
-
Coder v2
-
-
- - Manage - -
+
+
{user && }
) } diff --git a/site/components/User/UserAvatar.tsx b/site/components/User/UserAvatar.tsx new file mode 100644 index 0000000000000..020e6eb063ad5 --- /dev/null +++ b/site/components/User/UserAvatar.tsx @@ -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 = ({ user, className }) => { + return {firstLetter(user.username)} +} + +/** + * `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 "" +} diff --git a/site/components/User/UserProfileCard.tsx b/site/components/User/UserProfileCard.tsx new file mode 100644 index 0000000000000..b3cc1ec3deeb9 --- /dev/null +++ b/site/components/User/UserProfileCard.tsx @@ -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 = ({ user }) => { + const styles = useStyles() + + return ( +
+
+ +
+ {user.username} + {user.email} +
+ ) +} + +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), + }, +})) diff --git a/site/components/User/index.ts b/site/components/User/index.ts new file mode 100644 index 0000000000000..324a0afd7a931 --- /dev/null +++ b/site/components/User/index.ts @@ -0,0 +1,2 @@ +export * from "./UserAvatar" +export * from "./UserProfileCard" diff --git a/site/contexts/UserContext.test.tsx b/site/contexts/UserContext.test.tsx index 8b529841cbbf8..22685b6399468 100644 --- a/site/contexts/UserContext.test.tsx +++ b/site/contexts/UserContext.test.tsx @@ -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 { ) } - - 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(() => { diff --git a/site/contexts/UserContext.tsx b/site/contexts/UserContext.tsx index a09bab1f7725a..673c767d1e771 100644 --- a/site/contexts/UserContext.tsx +++ b/site/contexts/UserContext.tsx @@ -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 } -const UserContext = React.createContext({}) +const UserContext = React.createContext({ + 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 ( {props.children} diff --git a/site/pages/index.tsx b/site/pages/index.tsx index 5780010f734e7..0de405b8a74fb 100644 --- a/site/pages/index.tsx +++ b/site/pages/index.tsx @@ -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 @@ -29,7 +29,7 @@ const WorkspacesPage: React.FC = () => { return (
- +
color="primary" diff --git a/site/test_helpers/index.tsx b/site/test_helpers/index.tsx index 1c6c891d986b0..8242832ee7d50 100644 --- a/site/test_helpers/index.tsx +++ b/site/test_helpers/index.tsx @@ -11,3 +11,5 @@ export const WrapperComponent: React.FC = ({ children }) => { export const render = (component: React.ReactElement): RenderResult => { return wrappedRender({component}) } + +export * from "./user" diff --git a/site/test_helpers/user.ts b/site/test_helpers/user.ts new file mode 100644 index 0000000000000..652538ef19583 --- /dev/null +++ b/site/test_helpers/user.ts @@ -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: "", +}