Skip to content

Commit

Permalink
feat: profile menu w/ login/logout
Browse files Browse the repository at this point in the history
  • Loading branch information
activescott committed Feb 14, 2021
1 parent 82a7fba commit f89f9ed
Show file tree
Hide file tree
Showing 42 changed files with 598 additions and 263 deletions.
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"cSpell.words": ["cookieconsent", "csrf"]
"cSpell.words": ["Rollbar", "cookieconsent", "csrf"]
}
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,16 +96,16 @@ Some super helpful references to keep handy:

- [+] chore: posts to github releases (not to npm)

- [ ] feat: profile menu w/ login/logout (see alertgenie)
- [+] feat: profile menu w/ login/logout

- [ ] feat: logout endpoint (clears the session)

- [ ] feat: extract lambda/middleware into new package (@web-app-stack/lambda-auth)

- [ ] feat: CSRF token middleware in all state-changing APIs:

- [+] CSRF server support: automatic detection/rejection
- [ ] CSRF client support: Automatic inclusion of the token
- [ ] CSRF server support: automatic detection/rejection
- [+] CSRF client support: Automatic inclusion of the token

- UserContext:

Expand Down
4 changes: 4 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Data from "./pages/Data"
import Privacy from "./pages/policy/Privacy"
import Terms from "./pages/policy/Terms"
import { useCookieConsent } from "./lib/cookieConsent"
import Profile from "./pages/Profile"

export default function App(): JSX.Element {
useCookieConsent()
Expand All @@ -28,6 +29,9 @@ export default function App(): JSX.Element {
<Route exact path="/">
<Home />
</Route>
<Route exact path="/profile">
<Profile />
</Route>
<Route path="/data">
<Data />
</Route>
Expand Down
25 changes: 25 additions & 0 deletions client/src/components/IconicIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React, { CSSProperties } from "react"
import openIconicSprite from "open-iconic/sprite/open-iconic.min.svg"

interface Props {
icon: string
fillColor?: string
className?: string
style?: CSSProperties
}

const IconicIcon = (props: Props): JSX.Element => {
const className = props.className
? "iconic-icon ".concat(props.className)
: "iconic-icon"
return (
<svg viewBox="0 0 8 8" className={className} style={props.style}>
<use
xlinkHref={openIconicSprite + "#" + props.icon}
style={{ fill: props.fillColor }}
></use>
</svg>
)
}

export default IconicIcon
13 changes: 8 additions & 5 deletions client/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import "../style/style.scss"
import Foot from "./Foot"
import { Helmet } from "react-helmet"
import Iconic from "./iconic"
import { UserProvider } from "./auth/UserProvider"

// https://nextjs.org/learn/basics/using-shared-components/rendering-children-components

Expand All @@ -21,11 +22,13 @@ const Layout = (props: Props): JSX.Element => {
/>
</Helmet>
<Iconic />
<Nav />
<main id="content" className="py-5">
<div className="container">{props.children}</div>
</main>
<Foot />
<UserProvider>
<Nav />
<main id="content" className="py-5">
<div className="container">{props.children}</div>
</main>
<Foot />
</UserProvider>
</>
)
}
Expand Down
38 changes: 38 additions & 0 deletions client/src/components/Nav.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import React from "react"
import { Link } from "react-router-dom"
import logoLight from "../images/logo-light.svg"
import Avatar from "./auth/Avatar"
import LoginOrLogout from "./auth/LoginOrLogout"
import { useUserContext } from "./auth/UserProvider"

const links: {
href: string
Expand All @@ -25,6 +28,8 @@ function navItemClasses(isActive: boolean): string {
}

const Nav = (): JSX.Element => {
const { isAuthenticated, isLoading, user } = useUserContext()

return (
<header className="navbar navbar-expand-lg navbar-dark">
<a href={`${process.env.PUBLIC_URL}/`}>
Expand Down Expand Up @@ -71,9 +76,42 @@ const Nav = (): JSX.Element => {
)
})}
</ul>
<ul className="navbar-nav ml-auto">
<li key="profile-dropdown" className="nav-item dropdown">
<button
className="btn btn-link nav-link dropdown-toggle"
id="navbarDropdown"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
>
<Avatar isLoading={isLoading} user={user} />
</button>
<div
className="dropdown-menu dropdown-menu-right"
aria-labelledby="navbarDropdown"
>
<ProfileItem isAuthenticated={isAuthenticated} />
<LoginOrLogout />
</div>
</li>
</ul>
</div>
</header>
)
}

const ProfileItem = (props: {
isAuthenticated: boolean
}): JSX.Element | null => {
return props.isAuthenticated ? (
<>
<Link to="/profile">
<button className="btn btn-link dropdown-item">Profile</button>
</Link>
<div className="dropdown-divider"></div>
</>
) : null
}

export default Nav
25 changes: 25 additions & 0 deletions client/src/components/auth/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from "react"
import IconicIcon from "../IconicIcon"
import { AuthUser } from "./UserProvider"

interface Props {
isLoading: boolean
user: AuthUser | null
}

const Avatar = (props: Props): JSX.Element => {
const { isLoading, user } = props
if (isLoading) {
return <IconicIcon icon="loop-circular" />
} else if (!user) {
return <IconicIcon icon="person" />
} else {
return (
<>
<IconicIcon icon="person" fillColor="white" />
</>
)
}
}

export default Avatar
34 changes: 34 additions & 0 deletions client/src/components/auth/LoginOrLogout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React from "react"
import { useUserContext } from "./UserProvider"
import SignInWithApple from "./SignInWithApple"
import SignInWithGoogle from "./SignInWithGoogle"

const LoginOrLogout = (): JSX.Element => {
return (
<>
<LogoutLink />
<LoginLinks />
</>
)
}

const LogoutLink = (): JSX.Element | null => {
const { isAuthenticated, logout } = useUserContext()
return isAuthenticated ? (
<button className="dropdown-item" onClick={logout}>
Logout
</button>
) : null
}

const LoginLinks = (): JSX.Element | null => {
const { isAuthenticated } = useUserContext()
return isAuthenticated ? null : (
<>
<SignInWithApple buttonClassName="dropdown-item" />
<SignInWithGoogle buttonClassName="dropdown-item" />
</>
)
}

export default LoginOrLogout
11 changes: 8 additions & 3 deletions client/src/components/auth/SignInWithApple.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import React from "react"
import "./SignInWithApple.css"
import SignInWithProvider from "./SignInWithProvider"
import SignInWithProvider, {
SignInWithProviderProps,
} from "./SignInWithProvider"
import providerLogo from "./images/apple_left_black_logo_small.svg"
import { combineClassNames } from "../../lib/reactUtil"

const providerName = "APPLE"
const providerLabel = "Sign in with Apple"

type Props = Pick<SignInWithProviderProps, "buttonClassName">

// note styled according to https://developers.google.com/identity/branding-guidelines
const SignInWithApple = (): JSX.Element => {
const SignInWithApple = (props: Props): JSX.Element => {
return (
<SignInWithProvider
provider={providerName}
label={providerLabel}
logoUrl={providerLogo}
buttonClassName="aapl"
buttonClassName={combineClassNames("aapl ", props.buttonClassName)}
/>
)
}
Expand Down
13 changes: 9 additions & 4 deletions client/src/components/auth/SignInWithGoogle.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
import React from "react"
import "./SignInWithGoogle.css"
import SignInWithProvider from "./SignInWithProvider"
import SignInWithProvider, {
SignInWithProviderProps,
} from "./SignInWithProvider"
import providerLogo from "./images/btn_google_light_normal_ios.svg"
import { combineClassNames } from "../../lib/reactUtil"

const providerName = "GOOGLE"
const providerLabel = "Sign in with Google"

type Props = Pick<SignInWithProviderProps, "buttonClassName">

// note styled according to https://developers.google.com/identity/branding-guidelines
const SignInWithGoogle2 = (): JSX.Element => {
const SignInWithGoogle = (props: Props): JSX.Element => {
return (
<SignInWithProvider
provider={providerName}
label={providerLabel}
logoUrl={providerLogo}
buttonClassName="goog"
buttonClassName={combineClassNames("goog", props.buttonClassName)}
/>
)
}

export default SignInWithGoogle2
export default SignInWithGoogle
18 changes: 10 additions & 8 deletions client/src/components/auth/SignInWithProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
import React from "react"
import { combineClassNames } from "../../lib/reactUtil"
import "./SignInWithProvider.css"
import { doLogin } from "./authUtil"
import { useUserContext } from "./UserProvider"

interface Props {
export interface SignInWithProviderProps {
provider: string
label: string
logoUrl: string
buttonClassName?: string
}

const SignInWithProvider = (props: Props): JSX.Element => {
const buttonClassName =
"sign-in" + (props.buttonClassName ? " " + props.buttonClassName : "")
const SignInWithProvider = (props: SignInWithProviderProps): JSX.Element => {
const { login } = useUserContext()
return (
<button
className={buttonClassName}
onClick={() => doLogin(props.provider)}
className={combineClassNames("sign-in", props.buttonClassName)}
onClick={() => {
login(props.provider)
}}
{...props}
>
<img className="logo" src={props.logoUrl} />
<img className="logo" src={props.logoUrl} alt={props.label} />
<span className="label">{props.label}</span>
</button>
)
Expand Down
81 changes: 81 additions & 0 deletions client/src/components/auth/UserProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import React, { useContext, useEffect, useState } from "react"
import { fetchJson } from "../../lib/fetch"
import { doLogin, doLogout } from "./authUtil"

/** Defines the attributes of an authenticated user. */
export interface AuthUser {
/** The unique identifier for the user (the subject). */
sub: string
/** Time User last updated (milliseconds since epoch) */
updatedAt: number
/** If specified, specifies the email address of the user. */
email?: string
/** If specified, specifies the name of the user. */
name?: string
}

export interface ProvidedUserContext {
user: AuthUser | null
isAuthenticated: boolean
isLoading: boolean
login: (providerName: string) => Promise<void>
logout: () => Promise<void>
// TODO: add the below login/logout/token implementations:
// getAccessToken: (options?: AccessTokenOptions) => Promise<string>
}

const DefaultUserContext: ProvidedUserContext = {
user: null,
isAuthenticated: false,
isLoading: true,
login: async (providerName: string) => {
doLogin(providerName)
},
logout: async () => {
doLogout()
},
}

const UserContext = React.createContext<ProvidedUserContext>(DefaultUserContext)
export const useUserContext = (): ProvidedUserContext => useContext(UserContext)

interface Props {
children?: React.ReactNode
}

export const UserProvider = (props: Props): JSX.Element => {
const [user, setUser] = useState<AuthUser | null>(null)
const [isLoading, setIsLoading] = useState(true)

useEffect(() => {
async function go(): Promise<void> {
try {
// see if this user is authenticated by calling the UserInfo endpoint
const userInfo = await fetchJson<AuthUser>(
`${process.env.PUBLIC_URL}/auth/me`
)
setUser(userInfo)
} catch (e) {
// TODO: fix fetchJson so we can get the response code and get error body (AuthUser | AuthError).
// eslint-disable-next-line no-console
console.error("Failed to authenticate user: " + e.toString())
}
setIsLoading(false)
}
go()
}, [])

return (
<UserContext.Provider
value={{
user,
isAuthenticated: Boolean(user),
isLoading,
login: DefaultUserContext.login,
logout: DefaultUserContext.logout,
}}
>
{props.children}
</UserContext.Provider>
)
}

0 comments on commit f89f9ed

Please sign in to comment.