From 7a7133d1b768a6c15b5605205d56acb2be8e813d Mon Sep 17 00:00:00 2001 From: Jim O'Donnell Date: Thu, 12 Oct 2023 13:11:53 +0100 Subject: [PATCH 01/10] 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. --- packages/app-root/package.json | 10 +++- packages/app-root/server/server.js | 53 ++++++++++++++++ .../app-root/src/components/PageFooter.js | 13 ++++ .../app-root/src/components/PageHeader.js | 14 +++++ .../app-root/src/components/RootLayout.js | 9 +-- packages/app-root/src/hooks/index.js | 1 + .../app-root/src/hooks/usePanoptesUser.js | 60 +++++++++++++++++++ yarn.lock | 49 +++++++++++++-- 8 files changed, 197 insertions(+), 12 deletions(-) create mode 100644 packages/app-root/server/server.js create mode 100644 packages/app-root/src/components/PageFooter.js create mode 100644 packages/app-root/src/components/PageHeader.js create mode 100644 packages/app-root/src/hooks/index.js create mode 100644 packages/app-root/src/hooks/usePanoptesUser.js diff --git a/packages/app-root/package.json b/packages/app-root/package.json index 7f7bc2adea..73f3635b5a 100644 --- a/packages/app-root/package.json +++ b/packages/app-root/package.json @@ -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", @@ -17,9 +17,12 @@ "@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" @@ -28,6 +31,7 @@ "node": ">=20.5" }, "devDependencies": { - "@next/bundle-analyzer": "~13.5.4" + "@next/bundle-analyzer": "~13.5.4", + "selfsigned": "~2.1.1" } } diff --git a/packages/app-root/server/server.js b/packages/app-root/server/server.js new file mode 100644 index 0000000000..e69efba623 --- /dev/null +++ b/packages/app-root/server/server.js @@ -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}`) + }) + } +}) diff --git a/packages/app-root/src/components/PageFooter.js b/packages/app-root/src/components/PageFooter.js new file mode 100644 index 0000000000..84982f21a3 --- /dev/null +++ b/packages/app-root/src/components/PageFooter.js @@ -0,0 +1,13 @@ +'use client' +import ZooFooter from '@zooniverse/react-components/ZooFooter' + +import { usePanoptesUser } from '../hooks' + +export default function PageFooter() { + // we'll need the user in order to detect admin mode. + const { data: user } = usePanoptesUser() + + return ( + + ) +} \ No newline at end of file diff --git a/packages/app-root/src/components/PageHeader.js b/packages/app-root/src/components/PageHeader.js new file mode 100644 index 0000000000..b6d6a49db1 --- /dev/null +++ b/packages/app-root/src/components/PageHeader.js @@ -0,0 +1,14 @@ +'use client' +import ZooHeader from '@zooniverse/react-components/ZooHeader' + +import { usePanoptesUser } from '../hooks' + +export default function PageHeader() { + const { data: user } = usePanoptesUser() + + return ( + + ) +} \ No newline at end of file diff --git a/packages/app-root/src/components/RootLayout.js b/packages/app-root/src/components/RootLayout.js index 6d7d3e1471..6d53ee7a9d 100644 --- a/packages/app-root/src/components/RootLayout.js +++ b/packages/app-root/src/components/RootLayout.js @@ -8,8 +8,9 @@ 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' + +import PageHeader from './PageHeader.js' +import PageFooter from './PageFooter.js' const GlobalStyle = createGlobalStyle` body { @@ -28,9 +29,9 @@ export default function RootLayout({ children }) { }} theme={zooTheme} > - + {children} - + ) diff --git a/packages/app-root/src/hooks/index.js b/packages/app-root/src/hooks/index.js new file mode 100644 index 0000000000..31b4d47326 --- /dev/null +++ b/packages/app-root/src/hooks/index.js @@ -0,0 +1 @@ +export { default as usePanoptesUser } from './usePanoptesUser.js' diff --git a/packages/app-root/src/hooks/usePanoptesUser.js b/packages/app-root/src/hooks/usePanoptesUser.js new file mode 100644 index 0000000000..b1317043cf --- /dev/null +++ b/packages/app-root/src/hooks/usePanoptesUser.js @@ -0,0 +1,60 @@ +import auth from 'panoptes-client/lib/auth' +import { auth as authHelpers } from '@zooniverse/panoptes-js' +import { useEffect, useState } from 'react' + +if (typeof window !== 'undefined') { + auth.checkCurrent() +} + +async function fetchPanoptesUser() { + try { + const authorization = await auth.checkBearerToken() + /* + Use crypto.subtle as a proxy for checking for SSL. + It will only exist for https:// URLs. + */ + const isSecure = crypto?.subtle + if (authorization && isSecure) { + const { user, error } = await authHelpers.decodeJWT(authorization) + if (user) { + return user + } + if (error) { + throw error + } + } + } catch (error) { + console.log(error) + } + return await auth.checkCurrent() +} + +export default function usePanoptesUser() { + const [error, setError] = useState(null) + const [user, setUser] = useState({}) + const [loading, setLoading] = useState(true) + + useEffect(function () { + async function checkUserSession() { + setLoading(true) + try { + const panoptesUser = await fetchPanoptesUser() + setUser(panoptesUser) + } catch (error) { + setError(error) + } + setLoading(false) + } + + if (typeof window !== 'undefined') { + checkUserSession() + } + auth.listen('change', checkUserSession) + + return function () { + auth.stopListening('change', checkUserSession) + } + }, []) + + return { data: user, error, isLoading: loading } +} diff --git a/yarn.lock b/yarn.lock index a6918894d0..968cd44c6b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1983,7 +1983,7 @@ resolved "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz" integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ== -"@grpc/grpc-js@^1.9.4": +"@grpc/grpc-js@^1.8.10", "@grpc/grpc-js@^1.9.4": version "1.9.7" resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.9.7.tgz#7d0e29bc162287bee2523901c9bc9320d8402397" integrity sha512-yMaA/cIsRhGzW3ymCNpdlPcInXcovztlgu/rirThj2b87u3RzWUszliOqZ/pldy7yhmJPS8uwog+kZSTa4A0PQ== @@ -2260,12 +2260,12 @@ pump "^3.0.0" tar-fs "^2.1.1" -"@newrelic/aws-sdk@^7.0.2": +"@newrelic/aws-sdk@^7.0.0", "@newrelic/aws-sdk@^7.0.2": version "7.0.2" resolved "https://registry.yarnpkg.com/@newrelic/aws-sdk/-/aws-sdk-7.0.2.tgz#e93f1796c89be8323a75f3d7ec45b1bdd5a29292" integrity sha512-nT19hzId0MbjR3v1ks5YetvNfrwIEgMfeai+T2pQkuWkjCsYm3z+OybLOYMCN66gueqOOqGTq60qhM4dFu5s5w== -"@newrelic/koa@^8.0.1": +"@newrelic/koa@^8.0.0", "@newrelic/koa@^8.0.1": version "8.0.1" resolved "https://registry.yarnpkg.com/@newrelic/koa/-/koa-8.0.1.tgz#26c1c6a69b15ad4b64a148b6be537ec2ca734206" integrity sha512-GyeZGKPllpUu6gWXRwVP/FlvE9+tU2lOprRiTdoXNM8jdVGL02IfHnvAzrIANoZoUdf3+Vev8NNeCup2Eojcvg== @@ -2307,6 +2307,11 @@ uuid "^9.0.0" ws "^7.5.9" +"@newrelic/superagent@^7.0.0": + version "7.0.0" + resolved "https://registry.npmjs.org/@newrelic/superagent/-/superagent-7.0.0.tgz" + integrity sha512-fNB4NC+pJYYrFZRLcXaTb4Z7XFEfHi7fVQ3O9Qh10m/9CBM2W+Qc/6yyK9M1liRfgUGo5NOILRdjA23SS7720A== + "@newrelic/superagent@^7.0.1": version "7.0.1" resolved "https://registry.yarnpkg.com/@newrelic/superagent/-/superagent-7.0.1.tgz#8d5bb92579cf0b291e1298f480c4939a3d70ec09" @@ -9254,7 +9259,7 @@ execa@^5.0.0, execa@^5.1.1: signal-exit "^3.0.3" strip-final-newline "^2.0.0" -express@^4.17.1, express@^4.17.3: +express@^4.17.1, express@^4.17.3, express@~4.18.2: version "4.18.2" resolved "https://registry.npmjs.org/express/-/express-4.18.2.tgz" integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== @@ -12972,6 +12977,33 @@ newrelic@^11.0.0, newrelic@~11.4.0: "@newrelic/native-metrics" "^10.0.0" "@prisma/prisma-fmt-wasm" "^4.17.0-16.27eb2449f178cd9fe1a4b892d732cc4795f75085" +newrelic@~11.2.0: + version "11.2.0" + resolved "https://registry.yarnpkg.com/newrelic/-/newrelic-11.2.0.tgz#eded32c7b7d97cae36e45396e8926a201e441793" + integrity sha512-gkt6c5nphsKTRBmKd0H12xELwnhdV9Xph5CL8IXT7nj0C1gL/xxfuTrwj6g+JqDvVz983iNNfdfXBEhIUJC4nQ== + dependencies: + "@grpc/grpc-js" "^1.8.10" + "@grpc/proto-loader" "^0.7.5" + "@newrelic/aws-sdk" "^7.0.0" + "@newrelic/koa" "^8.0.0" + "@newrelic/security-agent" "0.3.0" + "@newrelic/superagent" "^7.0.0" + "@tyriar/fibonacci-heap" "^2.0.7" + concat-stream "^2.0.0" + https-proxy-agent "^7.0.1" + import-in-the-middle "^1.4.2" + json-bigint "^1.0.0" + json-stringify-safe "^5.0.0" + module-details-from-path "^1.0.3" + readable-stream "^3.6.1" + require-in-the-middle "^7.2.0" + semver "^7.5.2" + winston-transport "^4.5.0" + optionalDependencies: + "@contrast/fn-inspect" "^3.3.0" + "@newrelic/native-metrics" "^10.0.0" + "@prisma/prisma-fmt-wasm" "^4.17.0-16.27eb2449f178cd9fe1a4b892d732cc4795f75085" + next-absolute-url@~1.2.2: version "1.2.2" resolved "https://registry.npmjs.org/next-absolute-url/-/next-absolute-url-1.2.2.tgz" @@ -13814,7 +13846,7 @@ pako@~1.0.5: resolved "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz" integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== -panoptes-client@~5.5.1: +panoptes-client@~5.5.1, panoptes-client@~5.5.6: version "5.5.6" resolved "https://registry.npmjs.org/panoptes-client/-/panoptes-client-5.5.6.tgz" integrity sha512-TvcKIS7ggrfuh8dA+9ORgHw53lWCoRjyIZWtSjOGOlIIBB2QF+3dPEgyDUltQ6Kpo49TV7PRAYNczJI3GGn07w== @@ -15590,6 +15622,13 @@ selfsigned@^2.1.1, selfsigned@~2.4.1: "@types/node-forge" "^1.3.0" node-forge "^1" +selfsigned@~2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-2.1.2.tgz#9f9a4b0d472a5f29f892eb52358056c61a7387e3" + integrity sha512-xc6ZKMc9owNuU3uEPuW45RnSPylOlRK5Brj8oWf/2+BQV2gD1c+/eJaHFCcTG8w8kRkEfb5mzn/yIpie6gJ1tA== + dependencies: + node-forge "^1" + "semver@2 || 3 || 4 || 5", semver@^5.6.0: version "5.7.2" resolved "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz" From bb96440f24bbad6f8fac6c99c3543c2a5f676ac8 Mon Sep 17 00:00:00 2001 From: Jim O'Donnell Date: Fri, 13 Oct 2023 14:35:25 +0100 Subject: [PATCH 02/10] Use a global user object (experimental) --- .../app-root/src/hooks/usePanoptesUser.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/app-root/src/hooks/usePanoptesUser.js b/packages/app-root/src/hooks/usePanoptesUser.js index b1317043cf..d2998ba4ad 100644 --- a/packages/app-root/src/hooks/usePanoptesUser.js +++ b/packages/app-root/src/hooks/usePanoptesUser.js @@ -29,17 +29,30 @@ async function fetchPanoptesUser() { return await auth.checkCurrent() } +let user + export default function usePanoptesUser() { const [error, setError] = useState(null) - const [user, setUser] = useState({}) const [loading, setLoading] = useState(true) useEffect(function () { async function checkUserSession() { setLoading(true) try { - const panoptesUser = await fetchPanoptesUser() - setUser(panoptesUser) + const { + admin, + avatar_src, + display_name, + id, + login, + } = await fetchPanoptesUser() + user = { + admin, + avatar_src, + display_name, + id, + login, + } } catch (error) { setError(error) } From afe0f0df48a64913a86837cdce7c47d9cbd18263 Mon Sep 17 00:00:00 2001 From: Jim O'Donnell Date: Fri, 13 Oct 2023 14:36:48 +0100 Subject: [PATCH 03/10] Add unread messages and notifications - add `swr`. - `useUnreadMessages` fetches your unread message count with `useSWR`. - `useUnreadNotifications` fetchs your unread notifications count with `useSWR`. --- packages/app-root/package.json | 3 +- .../app-root/src/components/PageHeader.js | 10 +++- packages/app-root/src/hooks/index.js | 2 + .../app-root/src/hooks/useUnreadMessages.js | 55 +++++++++++++++++++ .../src/hooks/useUnreadNotifications.js | 36 ++++++++++++ yarn.lock | 2 +- 6 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 packages/app-root/src/hooks/useUnreadMessages.js create mode 100644 packages/app-root/src/hooks/useUnreadNotifications.js diff --git a/packages/app-root/package.json b/packages/app-root/package.json index 73f3635b5a..5cdc47da73 100644 --- a/packages/app-root/package.json +++ b/packages/app-root/package.json @@ -25,7 +25,8 @@ "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" diff --git a/packages/app-root/src/components/PageHeader.js b/packages/app-root/src/components/PageHeader.js index b6d6a49db1..2ffc97a7d4 100644 --- a/packages/app-root/src/components/PageHeader.js +++ b/packages/app-root/src/components/PageHeader.js @@ -1,13 +1,21 @@ 'use client' import ZooHeader from '@zooniverse/react-components/ZooHeader' -import { usePanoptesUser } from '../hooks' +import { + usePanoptesUser, + useUnreadMessages, + useUnreadNotifications +} from '../hooks' export default function PageHeader() { const { data: user } = usePanoptesUser() + const { data: unreadMessages }= useUnreadMessages(user) + const { data: unreadNotifications }= useUnreadNotifications(user) return ( ) diff --git a/packages/app-root/src/hooks/index.js b/packages/app-root/src/hooks/index.js index 31b4d47326..16926d8a13 100644 --- a/packages/app-root/src/hooks/index.js +++ b/packages/app-root/src/hooks/index.js @@ -1 +1,3 @@ export { default as usePanoptesUser } from './usePanoptesUser.js' +export { default as useUnreadMessages } from './useUnreadMessages.js' +export { default as useUnreadNotifications } from './useUnreadNotifications.js' diff --git a/packages/app-root/src/hooks/useUnreadMessages.js b/packages/app-root/src/hooks/useUnreadMessages.js new file mode 100644 index 0000000000..b92af111f3 --- /dev/null +++ b/packages/app-root/src/hooks/useUnreadMessages.js @@ -0,0 +1,55 @@ +import { talkAPI } from '@zooniverse/panoptes-js' +import auth from 'panoptes-client/lib/auth' +import useSWR from 'swr' + +const SWROptions = { + revalidateIfStale: true, + revalidateOnMount: true, + revalidateOnFocus: true, + revalidateOnReconnect: true, + refreshInterval: 0 +} + +async function fetchUnreadMessageCount({ endpoint = '/conversations' }) { + const token = await auth.checkBearerToken() + const authorization = `Bearer ${token}` + if (!authorization) return undefined + + let unreadConversationsIds = [] + + async function getConversations (page = 1) { + const query = { + unread: true, + page: page + } + + const response = await talkAPI.get(endpoint, query, { authorization }) + const { meta, conversations } = response?.body || {} + + if (conversations && conversations.length) { + unreadConversationsIds = unreadConversationsIds.concat( + conversations.map(conversation => conversation.id) + ) + } + + if (meta?.next_page) { + return getConversations(meta.next_page) + } + + return unreadConversationsIds + } + + await getConversations(1) + return unreadConversationsIds.length +} + +export default function useUnreadMessages(user) { + let key = null + if (user) { + key = { + user, + endpoint: '/conversations' + } + } + return useSWR(key, fetchUnreadMessageCount, SWROptions) +} diff --git a/packages/app-root/src/hooks/useUnreadNotifications.js b/packages/app-root/src/hooks/useUnreadNotifications.js new file mode 100644 index 0000000000..ab098060da --- /dev/null +++ b/packages/app-root/src/hooks/useUnreadNotifications.js @@ -0,0 +1,36 @@ +import { talkAPI } from '@zooniverse/panoptes-js' +import auth from 'panoptes-client/lib/auth' +import useSWR from 'swr' + +const SWROptions = { + revalidateIfStale: true, + revalidateOnMount: true, + revalidateOnFocus: true, + revalidateOnReconnect: true, + refreshInterval: 0 +} + +async function fetchUnreadNotificationsCount({ endpoint = '/notifications' }) { + const token = await auth.checkBearerToken() + const authorization = `Bearer ${token}` + if (!authorization) return undefined + + const query = { + delivered: false, + page_size: 1 + } + + const response = await talkAPI.get(endpoint, query, { authorization }) + return response?.body?.meta?.notifications?.count +} + +export default function useUnreadNotifications(user) { + let key = null + if (user) { + key = { + user, + endpoint: '/notifications' + } + } + return useSWR(key, fetchUnreadNotificationsCount, SWROptions) +} diff --git a/yarn.lock b/yarn.lock index 968cd44c6b..8d7486d5fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16440,7 +16440,7 @@ swc-loader@^0.2.3: resolved "https://registry.npmjs.org/swc-loader/-/swc-loader-0.2.3.tgz" integrity sha512-D1p6XXURfSPleZZA/Lipb3A8pZ17fP4NObZvFCDjK/OKljroqDpPmsBdTraWhVBqUNpcWBQY1imWdoPScRlQ7A== -swr@~2.2.0: +swr@~2.2.0, swr@~2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/swr/-/swr-2.2.4.tgz#03ec4c56019902fbdc904d78544bd7a9a6fa3f07" integrity sha512-njiZ/4RiIhoOlAaLYDqwz5qH/KZXVilRLvomrx83HjzCWTfa+InyfAjv05PSFxnmLzZkNO9ZfvgoqzAaEI4sGQ== From e6290e347ebfeb00b818f112d014c46087c55079 Mon Sep 17 00:00:00 2001 From: Jim O'Donnell Date: Fri, 13 Oct 2023 16:48:38 +0100 Subject: [PATCH 04/10] 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. --- .../app-root/src/components/PageFooter.js | 11 +++-- .../app-root/src/components/PageHeader.js | 3 ++ packages/app-root/src/hooks/index.js | 1 + packages/app-root/src/hooks/useAdminMode.js | 44 +++++++++++++++++++ .../app-root/src/hooks/usePanoptesUser.js | 29 ++++++------ 5 files changed, 70 insertions(+), 18 deletions(-) create mode 100644 packages/app-root/src/hooks/useAdminMode.js diff --git a/packages/app-root/src/components/PageFooter.js b/packages/app-root/src/components/PageFooter.js index 84982f21a3..6f47dec0d4 100644 --- a/packages/app-root/src/components/PageFooter.js +++ b/packages/app-root/src/components/PageFooter.js @@ -1,13 +1,16 @@ 'use client' +import AdminCheckbox from '@zooniverse/react-components/AdminCheckbox' import ZooFooter from '@zooniverse/react-components/ZooFooter' -import { usePanoptesUser } from '../hooks' +import { useAdminMode, usePanoptesUser } from '../hooks' export default function PageFooter() { - // we'll need the user in order to detect admin mode. - const { data: user } = usePanoptesUser() + const { data: user, isLoading } = usePanoptesUser() + const { adminMode, toggleAdmin } = useAdminMode(user) return ( - + : null} + /> ) } \ No newline at end of file diff --git a/packages/app-root/src/components/PageHeader.js b/packages/app-root/src/components/PageHeader.js index 2ffc97a7d4..7fbf01ffbf 100644 --- a/packages/app-root/src/components/PageHeader.js +++ b/packages/app-root/src/components/PageHeader.js @@ -2,6 +2,7 @@ import ZooHeader from '@zooniverse/react-components/ZooHeader' import { + useAdminMode, usePanoptesUser, useUnreadMessages, useUnreadNotifications @@ -11,9 +12,11 @@ export default function PageHeader() { const { data: user } = usePanoptesUser() const { data: unreadMessages }= useUnreadMessages(user) const { data: unreadNotifications }= useUnreadNotifications(user) + const { adminMode, toggleAdmin } = useAdminMode(user) 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 } +} \ No newline at end of file diff --git a/packages/app-root/src/hooks/usePanoptesUser.js b/packages/app-root/src/hooks/usePanoptesUser.js index d2998ba4ad..fb550c2595 100644 --- a/packages/app-root/src/hooks/usePanoptesUser.js +++ b/packages/app-root/src/hooks/usePanoptesUser.js @@ -29,7 +29,13 @@ async function fetchPanoptesUser() { return await auth.checkCurrent() } -let user +const isBrowser = typeof window !== 'undefined' +const localStorage = isBrowser ? window.localStorage : null +const storedUserJSON = localStorage?.getItem('panoptes-user') +let user = storedUserJSON && JSON.parse(storedUserJSON) +if (user === null) { + user = undefined +} export default function usePanoptesUser() { const [error, setError] = useState(null) @@ -39,19 +45,14 @@ export default function usePanoptesUser() { async function checkUserSession() { setLoading(true) try { - const { - admin, - avatar_src, - display_name, - id, - login, - } = await fetchPanoptesUser() - user = { - admin, - avatar_src, - display_name, - id, - login, + const panoptesUser = await fetchPanoptesUser() + if (panoptesUser) { + const { admin, avatar_src, display_name, id, login } = panoptesUser + user = { admin, avatar_src, display_name, id, login } + localStorage?.setItem('panoptes-user', JSON.stringify(user)) + } else { + user = undefined + localStorage?.removeItem('panoptes-user') } } catch (error) { setError(error) From b12b88b6896beda9344ff88adeeda21b5b9f7c67 Mon Sep 17 00:00:00 2001 From: Jim O'Donnell Date: Sat, 14 Oct 2023 11:59:21 +0100 Subject: [PATCH 05/10] Global page context Add `PageContextProvider`, responsible for: - global page styles. - providing the Zooniverse Grommet theme. - providing Panoptes auth context (user and admin mode.) --- .../src/components/PageContextProviders.js | 42 +++++++++++++++++++ .../app-root/src/components/PageFooter.js | 8 ++-- .../app-root/src/components/PageHeader.js | 8 ++-- .../app-root/src/components/RootLayout.js | 29 ++----------- .../src/contexts/PanoptesAuthContext.js | 5 +++ packages/app-root/src/contexts/index.js | 1 + 6 files changed, 59 insertions(+), 34 deletions(-) create mode 100644 packages/app-root/src/components/PageContextProviders.js create mode 100644 packages/app-root/src/contexts/PanoptesAuthContext.js create mode 100644 packages/app-root/src/contexts/index.js diff --git a/packages/app-root/src/components/PageContextProviders.js b/packages/app-root/src/components/PageContextProviders.js new file mode 100644 index 0000000000..a0dfffddc7 --- /dev/null +++ b/packages/app-root/src/components/PageContextProviders.js @@ -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 ( + + + + {children} + + + ) + +} \ No newline at end of file diff --git a/packages/app-root/src/components/PageFooter.js b/packages/app-root/src/components/PageFooter.js index 6f47dec0d4..90aff8b782 100644 --- a/packages/app-root/src/components/PageFooter.js +++ b/packages/app-root/src/components/PageFooter.js @@ -1,16 +1,16 @@ 'use client' import AdminCheckbox from '@zooniverse/react-components/AdminCheckbox' import ZooFooter from '@zooniverse/react-components/ZooFooter' +import { useContext } from 'react' -import { useAdminMode, usePanoptesUser } from '../hooks' +import { PanoptesAuthContext } from '../contexts' export default function PageFooter() { - const { data: user, isLoading } = usePanoptesUser() - const { adminMode, toggleAdmin } = useAdminMode(user) + const { adminMode, toggleAdmin, user } = useContext(PanoptesAuthContext) return ( : null} + adminContainer={user?.admin ? : null} /> ) } \ No newline at end of file diff --git a/packages/app-root/src/components/PageHeader.js b/packages/app-root/src/components/PageHeader.js index 7fbf01ffbf..4af1dd2ac8 100644 --- a/packages/app-root/src/components/PageHeader.js +++ b/packages/app-root/src/components/PageHeader.js @@ -1,18 +1,18 @@ 'use client' import ZooHeader from '@zooniverse/react-components/ZooHeader' +import { useContext } from 'react' import { - useAdminMode, - usePanoptesUser, useUnreadMessages, useUnreadNotifications } from '../hooks' +import { PanoptesAuthContext } from '../contexts' + export default function PageHeader() { - const { data: user } = usePanoptesUser() + const { adminMode, user } = useContext(PanoptesAuthContext) const { data: unreadMessages }= useUnreadMessages(user) const { data: unreadNotifications }= useUnreadNotifications(user) - const { adminMode, toggleAdmin } = useAdminMode(user) return ( - - + {children} - + ) } diff --git a/packages/app-root/src/contexts/PanoptesAuthContext.js b/packages/app-root/src/contexts/PanoptesAuthContext.js new file mode 100644 index 0000000000..c39b6e57f6 --- /dev/null +++ b/packages/app-root/src/contexts/PanoptesAuthContext.js @@ -0,0 +1,5 @@ +import { createContext } from 'react' + +const PanoptesAuthContext = createContext({}) + +export default PanoptesAuthContext diff --git a/packages/app-root/src/contexts/index.js b/packages/app-root/src/contexts/index.js new file mode 100644 index 0000000000..5c21818a37 --- /dev/null +++ b/packages/app-root/src/contexts/index.js @@ -0,0 +1 @@ +export { default as PanoptesAuthContext } from './PanoptesAuthContext.js' From 9d9f9ff12fec9ba1243a17abd36f17f4f4d2c779 Mon Sep 17 00:00:00 2001 From: Jim O'Donnell Date: Mon, 16 Oct 2023 09:47:06 +0100 Subject: [PATCH 06/10] Clean up usePanoptesUser - move `fetchPanoptesUser` into a helper. - add explanatory comments. - restore the missing `avatar_src` when getting the user object from a JWT. --- .../app-root/src/helpers/fetchPanoptesUser.js | 38 ++++++++++++++++ packages/app-root/src/helpers/index.js | 1 + .../app-root/src/hooks/usePanoptesUser.js | 44 ++++++------------- 3 files changed, 52 insertions(+), 31 deletions(-) create mode 100644 packages/app-root/src/helpers/fetchPanoptesUser.js create mode 100644 packages/app-root/src/helpers/index.js diff --git a/packages/app-root/src/helpers/fetchPanoptesUser.js b/packages/app-root/src/helpers/fetchPanoptesUser.js new file mode 100644 index 0000000000..989fe754b3 --- /dev/null +++ b/packages/app-root/src/helpers/fetchPanoptesUser.js @@ -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(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 } +} diff --git a/packages/app-root/src/helpers/index.js b/packages/app-root/src/helpers/index.js new file mode 100644 index 0000000000..025ab766d9 --- /dev/null +++ b/packages/app-root/src/helpers/index.js @@ -0,0 +1 @@ +export { default as fetchPanoptesUser } from './fetchPanoptesUser.js' diff --git a/packages/app-root/src/hooks/usePanoptesUser.js b/packages/app-root/src/hooks/usePanoptesUser.js index fb550c2595..ed069c7cad 100644 --- a/packages/app-root/src/hooks/usePanoptesUser.js +++ b/packages/app-root/src/hooks/usePanoptesUser.js @@ -1,38 +1,21 @@ import auth from 'panoptes-client/lib/auth' -import { auth as authHelpers } from '@zooniverse/panoptes-js' import { useEffect, useState } from 'react' -if (typeof window !== 'undefined') { - auth.checkCurrent() -} +import { fetchPanoptesUser } from '../helpers' -async function fetchPanoptesUser() { - try { - const authorization = await auth.checkBearerToken() - /* - Use crypto.subtle as a proxy for checking for SSL. - It will only exist for https:// URLs. - */ - const isSecure = crypto?.subtle - if (authorization && isSecure) { - const { user, error } = await authHelpers.decodeJWT(authorization) - if (user) { - return user - } - if (error) { - throw error - } - } - } catch (error) { - console.log(error) - } - return await auth.checkCurrent() +const isBrowser = typeof window !== 'undefined' + +if (isBrowser) { + auth.checkCurrent() } -const isBrowser = typeof window !== 'undefined' const localStorage = isBrowser ? window.localStorage : null const storedUserJSON = localStorage?.getItem('panoptes-user') let user = storedUserJSON && JSON.parse(storedUserJSON) +/* + Null users crash the ZooHeader component. + Set them to undefined for now. +*/ if (user === null) { user = undefined } @@ -45,11 +28,10 @@ export default function usePanoptesUser() { async function checkUserSession() { setLoading(true) try { - const panoptesUser = await fetchPanoptesUser() + const panoptesUser = await fetchPanoptesUser(user) if (panoptesUser) { - const { admin, avatar_src, display_name, id, login } = panoptesUser - user = { admin, avatar_src, display_name, id, login } - localStorage?.setItem('panoptes-user', JSON.stringify(user)) + localStorage?.setItem('panoptes-user', JSON.stringify(panoptesUser)) + user = panoptesUser } else { user = undefined localStorage?.removeItem('panoptes-user') @@ -60,7 +42,7 @@ export default function usePanoptesUser() { setLoading(false) } - if (typeof window !== 'undefined') { + if (isBrowser) { checkUserSession() } auth.listen('change', checkUserSession) From a2cda992de19496c859c226014822c75ed30a289 Mon Sep 17 00:00:00 2001 From: Jim O'Donnell Date: Wed, 18 Oct 2023 10:12:06 +0100 Subject: [PATCH 07/10] Configure ESLint --- packages/app-root/.eslintrc.json | 20 ++++++++++++++++++++ packages/app-root/package.json | 4 +++- yarn.lock | 2 +- 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 packages/app-root/.eslintrc.json diff --git a/packages/app-root/.eslintrc.json b/packages/app-root/.eslintrc.json new file mode 100644 index 0000000000..5522f86278 --- /dev/null +++ b/packages/app-root/.eslintrc.json @@ -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" + } + } + ] +} diff --git a/packages/app-root/package.json b/packages/app-root/package.json index 5cdc47da73..370d1c37db 100644 --- a/packages/app-root/package.json +++ b/packages/app-root/package.json @@ -32,7 +32,9 @@ "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" } } diff --git a/yarn.lock b/yarn.lock index 8d7486d5fd..6edc4d73fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2317,7 +2317,7 @@ resolved "https://registry.yarnpkg.com/@newrelic/superagent/-/superagent-7.0.1.tgz#8d5bb92579cf0b291e1298f480c4939a3d70ec09" integrity sha512-QZlW0VxHSVOXcMAtlkg+Mth0Nz3vFku8rfzTEmoI/pXcckHXGEYuiVUhhboCTD3xTKVgnZRUp9BWF6SOggGUSw== -"@next/bundle-analyzer@~13.5.4": +"@next/bundle-analyzer@~13.5.5": version "13.5.5" resolved "https://registry.yarnpkg.com/@next/bundle-analyzer/-/bundle-analyzer-13.5.5.tgz#301edbfe05ff910ce3c9ba691ea2a6257e0032cb" integrity sha512-v69BJm8ONM/e6l39Ao0ar8TwZyFnhI5s6id8LGayNq/3JaqkbzW97bIcBkTI0H9RiX3zZNIiaIyMgdKcbJqvsw== From e1b6239f23a87953b35f9fd6ad73cff0ce140bbf Mon Sep 17 00:00:00 2001 From: Jim O'Donnell Date: Wed, 18 Oct 2023 10:24:36 +0100 Subject: [PATCH 08/10] Optimise package imports --- packages/app-root/next.config.mjs | 6 +++++- packages/app-root/src/components/PageFooter.js | 3 +-- packages/app-root/src/components/PageHeader.js | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/app-root/next.config.mjs b/packages/app-root/next.config.mjs index a2a6e99052..026a6646f5 100644 --- a/packages/app-root/next.config.mjs +++ b/packages/app-root/next.config.mjs @@ -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) diff --git a/packages/app-root/src/components/PageFooter.js b/packages/app-root/src/components/PageFooter.js index 90aff8b782..934dd72a4c 100644 --- a/packages/app-root/src/components/PageFooter.js +++ b/packages/app-root/src/components/PageFooter.js @@ -1,6 +1,5 @@ 'use client' -import AdminCheckbox from '@zooniverse/react-components/AdminCheckbox' -import ZooFooter from '@zooniverse/react-components/ZooFooter' +import { AdminCheckbox, ZooFooter } from '@zooniverse/react-components' import { useContext } from 'react' import { PanoptesAuthContext } from '../contexts' diff --git a/packages/app-root/src/components/PageHeader.js b/packages/app-root/src/components/PageHeader.js index 4af1dd2ac8..10e7ee4216 100644 --- a/packages/app-root/src/components/PageHeader.js +++ b/packages/app-root/src/components/PageHeader.js @@ -1,5 +1,5 @@ 'use client' -import ZooHeader from '@zooniverse/react-components/ZooHeader' +import { ZooHeader } from '@zooniverse/react-components' import { useContext } from 'react' import { From 8eefa1c90f401ea5f81eaa6f72227d802fe503c4 Mon Sep 17 00:00:00 2001 From: Jim O'Donnell Date: Mon, 23 Oct 2023 14:20:03 +0100 Subject: [PATCH 09/10] 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. --- .../app-root/src/helpers/fetchPanoptesUser.js | 2 +- .../app-root/src/hooks/usePanoptesUser.js | 70 +++++++++++-------- 2 files changed, 41 insertions(+), 31 deletions(-) diff --git a/packages/app-root/src/helpers/fetchPanoptesUser.js b/packages/app-root/src/helpers/fetchPanoptesUser.js index 989fe754b3..caa89f3676 100644 --- a/packages/app-root/src/helpers/fetchPanoptesUser.js +++ b/packages/app-root/src/helpers/fetchPanoptesUser.js @@ -5,7 +5,7 @@ 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(storedUser) { +export default async function fetchPanoptesUser({ user: storedUser }) { try { const jwt = await auth.checkBearerToken() /* diff --git a/packages/app-root/src/hooks/usePanoptesUser.js b/packages/app-root/src/hooks/usePanoptesUser.js index ed069c7cad..4203507a95 100644 --- a/packages/app-root/src/hooks/usePanoptesUser.js +++ b/packages/app-root/src/hooks/usePanoptesUser.js @@ -1,56 +1,66 @@ import auth from 'panoptes-client/lib/auth' -import { useEffect, useState } from 'react' +import { useEffect } from 'react' +import useSWR from 'swr' import { fetchPanoptesUser } from '../helpers' const isBrowser = typeof window !== 'undefined' +const SWROptions = { + revalidateIfStale: true, + revalidateOnMount: true, + revalidateOnFocus: true, + revalidateOnReconnect: true, + refreshInterval: 0 +} + if (isBrowser) { auth.checkCurrent() } const localStorage = isBrowser ? window.localStorage : null const storedUserJSON = localStorage?.getItem('panoptes-user') -let user = storedUserJSON && JSON.parse(storedUserJSON) +let storedUser = storedUserJSON && JSON.parse(storedUserJSON) /* Null users crash the ZooHeader component. Set them to undefined for now. */ -if (user === null) { - user = undefined +if (storedUser === null) { + storedUser = undefined } export default function usePanoptesUser() { - const [error, setError] = useState(null) - const [loading, setLoading] = useState(true) - - useEffect(function () { - async function checkUserSession() { - setLoading(true) - try { - const panoptesUser = await fetchPanoptesUser(user) - if (panoptesUser) { - localStorage?.setItem('panoptes-user', JSON.stringify(panoptesUser)) - user = panoptesUser - } else { - user = undefined - localStorage?.removeItem('panoptes-user') - } - } catch (error) { - setError(error) - } - setLoading(false) - } + const key = { + user: storedUser, + endpoint: '/me' + } - if (isBrowser) { - checkUserSession() - } - auth.listen('change', checkUserSession) + /* + `useSWR` here will always return the same stale user object. + See https://github.com/zooniverse/panoptes-javascript-client/issues/207 + */ + const { data, error, isLoading } = useSWR(key, fetchPanoptesUser, SWROptions) + if (data) { + storedUser = data + } + + useEffect(function subscribeToAuthChanges() { + auth.listen('change', auth.checkCurrent) return function () { - auth.stopListening('change', checkUserSession) + auth.stopListening('change', auth.checkCurrent) } }, []) - return { data: user, error, isLoading: loading } + useEffect(function persistUserInStorage() { + if (data) { + localStorage?.setItem('panoptes-user', JSON.stringify(data)) + } + + return () => { + localStorage?.removeItem('panoptes-user') + } + }, [data]) + + return { data: storedUser, error, isLoading } } From b90779cf90c5a56e4b33600ea48fb6da7bd6d9a1 Mon Sep 17 00:00:00 2001 From: Jim O'Donnell Date: Fri, 27 Oct 2023 15:16:16 +0100 Subject: [PATCH 10/10] Add named page headers --- packages/app-root/src/app/about/page.js | 3 +++ packages/app-root/src/app/projects/page.js | 3 +++ packages/app-root/src/components/PageHeader.js | 14 ++++++++------ 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/app-root/src/app/about/page.js b/packages/app-root/src/app/about/page.js index 197074ea7e..ad09698f91 100644 --- a/packages/app-root/src/app/about/page.js +++ b/packages/app-root/src/app/about/page.js @@ -1,5 +1,8 @@ export default function AboutPage() { return ( +
+

This is the section header.

+

This is lib-content-pages

diff --git a/packages/app-root/src/app/projects/page.js b/packages/app-root/src/app/projects/page.js index f4575f3406..f7fc4be5b3 100644 --- a/packages/app-root/src/app/projects/page.js +++ b/packages/app-root/src/app/projects/page.js @@ -1,5 +1,8 @@ export default function ProjectPage() { return ( +
+

This is the project header.

+

This is lib-project

diff --git a/packages/app-root/src/components/PageHeader.js b/packages/app-root/src/components/PageHeader.js index 10e7ee4216..82e1119a13 100644 --- a/packages/app-root/src/components/PageHeader.js +++ b/packages/app-root/src/components/PageHeader.js @@ -15,11 +15,13 @@ export default function PageHeader() { const { data: unreadNotifications }= useUnreadNotifications(user) return ( - +
+ +
) } \ No newline at end of file