Skip to content

Commit

Permalink
Add Panoptes auth to app-root (#5459)
Browse files Browse the repository at this point in the history
* add Panoptes auth to app-root
- add the Panoptes auth client, from `panoptes-client`.
- add `usePanoptesUser` for SWR-style user fetching.
- add an express server to handle HTTPS in local development.
- add the panoptes user to both page header and footer.

* Use a global user object (experimental)

* Add unread messages and notifications
- add `swr`.
- `useUnreadMessages` fetches your unread message count with `useSWR`.
- `useUnreadNotifications` fetchs your unread notifications count with `useSWR`.

* Add admin mode
- persist the current user in local storage.
- check admin mode with `useAdminMode`.
- add the admin mode toggle to the footer, and the admin mode border to the page.

* Global page context
Add `PageContextProvider`, responsible for:
- global page styles.
- providing the Zooniverse Grommet theme.
- providing Panoptes auth context (user and admin mode.)

* Clean up usePanoptesUser
- move `fetchPanoptesUser` into a helper.
- add explanatory comments.
- restore the missing `avatar_src` when getting the user object from a JWT.

* Configure ESLint

* Optimise package imports

* Rewrite usePanoptesUser with useSWR

- use `useSWR` to fetch the user object from Panoptes.
- persist the returned data in local storage.
- reset the current Panoptes session when auth changes.

* Add named page headers
  • Loading branch information
eatyourgreens authored Oct 28, 2023
1 parent 8a48d12 commit 5a9a09f
Show file tree
Hide file tree
Showing 20 changed files with 482 additions and 41 deletions.
20 changes: 20 additions & 0 deletions packages/app-root/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"extends": [
"next/core-web-vitals",
"plugin:jsx-a11y/recommended",
"plugin:@next/next/recommended"
],
"rules": {
"consistent-return": "error"
},
"overrides": [
{
"files": [
"src/**/*.stories.js"
],
"rules": {
"import/no-anonymous-default-export": "off"
}
}
]
}
6 changes: 5 additions & 1 deletion packages/app-root/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ const bundleAnalyzer = withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
})

const nextConfig = {}
const nextConfig = {
experimental: {
optimizePackageImports: ['@zooniverse/react-components', 'grommet', 'grommet-icons'],
}
}

export default bundleAnalyzer(nextConfig)
15 changes: 11 additions & 4 deletions packages/app-root/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "APP_ENV=${APP_ENV:-development} PANOPTES_ENV=${PANOPTES_ENV:-staging} node server/server.js",
"build": "next build",
"start": "next start",
"start": "NODE_ENV=${NODE_ENV:-production} PANOPTES_ENV=${PANOPTES_ENV:-production} node server/server.js",
"lint": "next lint"
},
"type": "module",
Expand All @@ -17,17 +17,24 @@
"@zooniverse/grommet-theme": "~3.1.1",
"@zooniverse/panoptes-js": "~0.4.1",
"@zooniverse/react-components": "~1.6.1",
"express": "~4.18.2",
"grommet": "~2.33.2",
"grommet-icons": "~4.11.0",
"newrelic": "~11.2.0",
"next": "~13.5.5",
"panoptes-client": "~5.5.6",
"react": "~18.2.0",
"react-dom": "~18.2.0",
"styled-components": "~5.3.10"
"styled-components": "~5.3.10",
"swr": "~2.2.4"
},
"engines": {
"node": ">=20.5"
},
"devDependencies": {
"@next/bundle-analyzer": "~13.5.4"
"@next/bundle-analyzer": "~13.5.5",
"eslint-config-next": "~13.5.5",
"eslint-plugin-jsx-a11y": "~6.7.0",
"selfsigned": "~2.1.1"
}
}
53 changes: 53 additions & 0 deletions packages/app-root/server/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
if (process.env.NEWRELIC_LICENSE_KEY) {
await import('newrelic')
}

import express from 'express'
import next from 'next'

const port = parseInt(process.env.PORT, 10) || 3000
const dev = process.env.NODE_ENV !== 'production'

const APP_ENV = process.env.APP_ENV || 'development'

const hostnames = {
development: 'local.zooniverse.org',
branch: 'fe-project-branch.preview.zooniverse.org',
staging: 'frontend.preview.zooniverse.org',
production : 'www.zooniverse.org'
}
const hostname = hostnames[APP_ENV]

const app = next({ dev, hostname, port })
const handle = app.getRequestHandler()

app.prepare().then(async () => {
const server = express()

server.get('*', (req, res) => {
return handle(req, res)
})

let selfsigned
try {
selfsigned = await import('selfsigned')
} catch (error) {
console.error(error)
}
if (APP_ENV === 'development' && selfsigned) {
const https = await import('https')

const attrs = [{ name: 'commonName', value: hostname }];
const { cert, private: key } = selfsigned.generate(attrs, { days: 365 })
return https.createServer({ cert, key }, server)
.listen(port, err => {
if (err) throw err
console.log(`> Ready on https://${hostname}:${port}`)
})
} else {
return server.listen(port, err => {
if (err) throw err
console.log(`> Ready on http://${hostname}:${port}`)
})
}
})
3 changes: 3 additions & 0 deletions packages/app-root/src/app/about/page.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export default function AboutPage() {
return (
<header aria-label='About the Zooniverse'>
<p>This is the section header.</p>
</header>
<div>
<p>This is lib-content-pages</p>
</div>
Expand Down
3 changes: 3 additions & 0 deletions packages/app-root/src/app/projects/page.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export default function ProjectPage() {
return (
<header aria-label='Project header'>
<p>This is the project header.</p>
</header>
<div>
<p>This is lib-project</p>
</div>
Expand Down
42 changes: 42 additions & 0 deletions packages/app-root/src/components/PageContextProviders.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use client'

import zooTheme from '@zooniverse/grommet-theme'
import { Grommet } from 'grommet'
import { createGlobalStyle } from 'styled-components'

import { PanoptesAuthContext } from '../contexts'
import { useAdminMode, usePanoptesUser } from '../hooks'

const GlobalStyle = createGlobalStyle`
body {
margin: 0;
}
`

/**
Context for every page:
- global page styles.
- Zooniverse Grommet theme.
- Panoptes auth (user account and admin mode.)
*/
export default function PageContextProviders({ children }) {
const { data: user, error, isLoading } = usePanoptesUser()
const { adminMode, toggleAdmin } = useAdminMode(user)
const authContext = { adminMode, error, isLoading, toggleAdmin, user }

return (
<PanoptesAuthContext.Provider value={authContext}>
<GlobalStyle />
<Grommet
background={{
dark: 'dark-1',
light: 'light-1'
}}
theme={zooTheme}
>
{children}
</Grommet>
</PanoptesAuthContext.Provider>
)

}
15 changes: 15 additions & 0 deletions packages/app-root/src/components/PageFooter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use client'
import { AdminCheckbox, ZooFooter } from '@zooniverse/react-components'
import { useContext } from 'react'

import { PanoptesAuthContext } from '../contexts'

export default function PageFooter() {
const { adminMode, toggleAdmin, user } = useContext(PanoptesAuthContext)

return (
<ZooFooter
adminContainer={user?.admin ? <AdminCheckbox onChange={toggleAdmin} checked={adminMode} /> : null}
/>
)
}
27 changes: 27 additions & 0 deletions packages/app-root/src/components/PageHeader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use client'
import { ZooHeader } from '@zooniverse/react-components'
import { useContext } from 'react'

import {
useUnreadMessages,
useUnreadNotifications
} from '../hooks'

import { PanoptesAuthContext } from '../contexts'

export default function PageHeader() {
const { adminMode, user } = useContext(PanoptesAuthContext)
const { data: unreadMessages }= useUnreadMessages(user)
const { data: unreadNotifications }= useUnreadNotifications(user)

return (
<header aria-label='Zooniverse site header'>
<ZooHeader
isAdmin={adminMode}
unreadMessages={unreadMessages}
unreadNotifications={unreadNotifications}
user={user}
/>
</header>
)
}
36 changes: 7 additions & 29 deletions packages/app-root/src/components/RootLayout.js
Original file line number Diff line number Diff line change
@@ -1,37 +1,15 @@
'use client'
/**
* Note that all child components are now client components.
* If we want children of RootLayout to be server components
* a ZooHeaderContainer and ZooFooterContainer could be created instead.
*/

import { createGlobalStyle } from 'styled-components'
import { Grommet } from 'grommet'
import zooTheme from '@zooniverse/grommet-theme'
import ZooHeader from '@zooniverse/react-components/ZooHeader'
import ZooFooter from '@zooniverse/react-components/ZooFooter'

const GlobalStyle = createGlobalStyle`
body {
margin: 0;
}
`
import PageContextProviders from './PageContextProviders.js'
import PageHeader from './PageHeader.js'
import PageFooter from './PageFooter.js'

export default function RootLayout({ children }) {
return (
<body>
<GlobalStyle />
<Grommet
background={{
dark: 'dark-1',
light: 'light-1'
}}
theme={zooTheme}
>
<ZooHeader />
<PageContextProviders>
<PageHeader />
{children}
<ZooFooter />
</Grommet>
<PageFooter />
</PageContextProviders>
</body>
)
}
5 changes: 5 additions & 0 deletions packages/app-root/src/contexts/PanoptesAuthContext.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createContext } from 'react'

const PanoptesAuthContext = createContext({})

export default PanoptesAuthContext
1 change: 1 addition & 0 deletions packages/app-root/src/contexts/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as PanoptesAuthContext } from './PanoptesAuthContext.js'
38 changes: 38 additions & 0 deletions packages/app-root/src/helpers/fetchPanoptesUser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import auth from 'panoptes-client/lib/auth'
import { auth as authHelpers } from '@zooniverse/panoptes-js'

/**
Get a Panoptes user from a Panoptes JSON Web Token (JWT), if we have one, or from
the Panoptes API otherwise.
*/
export default async function fetchPanoptesUser({ user: storedUser }) {
try {
const jwt = await auth.checkBearerToken()
/*
`crypto.subtle` is needed to decrypt the Panoptes JWT.
It will only exist for https:// URLs.
*/
const isSecure = crypto?.subtle
if (jwt && isSecure) {
/*
avatar_src isn't encoded in the Panoptes JWT, so we need to add it.
https://github.com/zooniverse/panoptes/issues/4217
*/
const { user, error } = await authHelpers.decodeJWT(jwt)
if (user) {
const { admin, display_name, id, login } = user
return {
avatar_src: storedUser.avatar_src,
...user
}
}
if (error) {
throw error
}
}
} catch (error) {
console.log(error)
}
const { admin, avatar_src, display_name, id, login } = await auth.checkCurrent()
return { admin, avatar_src, display_name, id, login }
}
1 change: 1 addition & 0 deletions packages/app-root/src/helpers/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as fetchPanoptesUser } from './fetchPanoptesUser.js'
4 changes: 4 additions & 0 deletions packages/app-root/src/hooks/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { default as useAdminMode } from './useAdminMode.js'
export { default as usePanoptesUser } from './usePanoptesUser.js'
export { default as useUnreadMessages } from './useUnreadMessages.js'
export { default as useUnreadNotifications } from './useUnreadNotifications.js'
44 changes: 44 additions & 0 deletions packages/app-root/src/hooks/useAdminMode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react'

const isBrowser = typeof window !== 'undefined'
const localStorage = isBrowser ? window.localStorage : null
const storedAdminFlag = !!localStorage?.getItem('adminFlag')
const adminBorderImage = 'repeating-linear-gradient(45deg,#000,#000 25px,#ff0 25px,#ff0 50px) 5'

export default function useAdminMode(user) {
const [adminState, setAdminState] = useState(storedAdminFlag)
const adminMode = user?.admin && adminState

useEffect(function onUserChange() {
const isAdmin = user?.admin
if (isAdmin) {
const adminFlag = !!localStorage?.getItem('adminFlag')
setAdminState(adminFlag)
} else {
localStorage?.removeItem('adminFlag')
}
}, [user?.admin])

useEffect(function onAdminChange() {
if (adminMode) {
document.body.style.border = '5px solid'
document.body.style.borderImage = adminBorderImage
}
return () => {
document.body.style.border = ''
document.body.style.borderImage = ''
}
}, [adminMode])

function toggleAdmin() {
let newAdminState = !adminState
setAdminState(newAdminState)
if (newAdminState) {
localStorage?.setItem('adminFlag', true)
} else {
localStorage?.removeItem('adminFlag')
}
}

return { adminMode, toggleAdmin }
}
Loading

0 comments on commit 5a9a09f

Please sign in to comment.