mirror of https://github.com/coder/coder.git
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:
parent
a44056cff5
commit
69d88b4a6d
|
@ -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)
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
site/api.ts
13
site/api.ts
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,3 +1,4 @@
|
|||
export { CoderIcon } from "./CoderIcon"
|
||||
export { Logo } from "./Logo"
|
||||
export * from "./Logout"
|
||||
export { WorkspacesIcon } from "./WorkspacesIcon"
|
||||
|
|
|
@ -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)}`,
|
||||
},
|
||||
}))
|
|
@ -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,
|
||||
},
|
||||
}))
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 ""
|
||||
}
|
|
@ -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),
|
||||
},
|
||||
}))
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./UserAvatar"
|
||||
export * from "./UserProfileCard"
|
|
@ -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(() => {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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: "",
|
||||
}
|
Loading…
Reference in New Issue