Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Panoptes auth to app-root #5459

Merged
merged 10 commits into from
Oct 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because this express server is only used for local development, do we need new relic? Just want to think through this server while we have the chance and consider the build bug from app-content-pages too: #5428

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The express server runs on yarn start too, but we need to test that on Kubernetes.

Your comment reminds me that the live content pages app isn't logging to New Relic either.

Copy link
Contributor

@goplayoutside3 goplayoutside3 Oct 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right! I think that's what I'm getting at. The content pages app will probably never use a custom express server in deployment again, so New Relic should not be setup for app-root either. Thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we debug slow pages and Postgres queries without New Relic?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, content pages should be using New Relic in production too. It should be imported when the Next app loads and starts, at the top of next.config.js.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the debugging via New Relic happen via logging from a production deploy, a local build, or both?

Copy link
Contributor

@goplayoutside3 goplayoutside3 Oct 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 app-project does have New Relic imported into next.config.js but app-content-pages does not.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any environment that has NEWRELIC_LICENSE_KEY set, according to the code. That’s production and staging deploys.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like they only have examples for the pages router.
https://github.com/newrelic-experimental/newrelic-nextjs-integration

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In addition to lack of New Relic + App Router documentation, I strongly suggest that New Relic isn't included in app-root (for now). We don't really know what deployment of this app will look like yet, and I'd prefer to setup logging tools that are used in staging + production envs when we get to that step. I've added it as a to-do in the project board.

Otherwise everything here is looking good!

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',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
branch: 'fe-project-branch.preview.zooniverse.org',

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change will crash when APP_ENV is set to 'branch' but I'm not sure if that's a big deal.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is branch included here in anticipation of doing a branch deploy with app-root?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it’s just in all the server setups by default. The content pages app defines it too.

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