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

feat: data portal menu item #1071

Merged
merged 13 commits into from
Feb 7, 2024
6 changes: 6 additions & 0 deletions apps/app/public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,12 @@
"take-action": "Take Action",
"uncheck-all": "Uncheck all",
"user-avatar": "User avatar",
"user-menu": {
"admin-options": "Admin Options",
"data-portal": "Data Portal Home",
"edit-page": "Edit this page",
"user-options": "User Options"
},
"verify-account": {
"verified": "Account verified!",
"verified-body": "<Text>Your account has been verified! Please <LoginModal>login</LoginModal> to start using InReach's additional features.",
Expand Down
3 changes: 3 additions & 0 deletions apps/app/src/pages/api/i18n/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ export default async function handler(req: NextRequest) {
// res.status(200).json(data)
return new Response(JSON.stringify(data), {
status: 200,
headers: {
'Cache-Control': 's-maxage=1, public, stale-while-revalidate=3600',
},
})
} catch (error) {
log.error(error)
Expand Down
3 changes: 2 additions & 1 deletion apps/app/src/pages/api/trpc/[trpc].ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,11 @@ export default createNextApiHandler({
const isQuery = type === 'query'

if (ctx?.res && !shouldSkip && allOk && isQuery) {
console.debug('[tRPC] Setting Cache headers in response.')
const ONE_DAY_IN_SECONDS = 60 * 60 * 24
return {
headers: {
'cache-control': `s-maxage=1, stale-while-revalidate=${ONE_DAY_IN_SECONDS}`,
'Cache-Control': `s-maxage=1, public, stale-while-revalidate=${ONE_DAY_IN_SECONDS}`,
},
}
}
Expand Down
42 changes: 7 additions & 35 deletions apps/app/src/pages/org/[slug]/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createStyles, Divider, Grid, Skeleton, Stack, Tabs, useMantineTheme } from '@mantine/core'

Check warning on line 1 in apps/app/src/pages/org/[slug]/index.tsx

View check run for this annotation

InReachBot / Check Code for Errors

apps/app/src/pages/org/[slug]/index.tsx#L1

[@typescript-eslint/no-unused-vars] 'Skeleton' is defined but never used. Allowed unused vars must match /^_/u.
import { useElementSize, useMediaQuery } from '@mantine/hooks'
import { type GetStaticPaths, type GetStaticPropsContext, type InferGetStaticPropsType } from 'next'
import dynamic from 'next/dynamic'
Expand All @@ -23,34 +23,13 @@
import { OrgPageLoading } from '@weareinreach/ui/loading-states/OrgPage'
import { api } from '~app/utils/api'
import { getServerSideTranslations } from '~app/utils/i18n'
import { nsFormatter } from '~app/utils/nsFormatter'

const GoogleMap = dynamic(() =>
import('@weareinreach/ui/components/core/GoogleMap').then((mod) => mod.GoogleMap)
)
const LoadingState = () => (
<>
<Grid.Col sm={8} order={1} pb={40}>
{/* Toolbar */}
<Skeleton h={48} w='100%' radius={8} />
<Stack pt={24} align='flex-start' spacing={40}>
{/* Listing Basic */}
<Skeleton h={260} w='100%' />
{/* Body */}
<Skeleton h={520} w='100%' />
{/* Tab panels */}
</Stack>
</Grid.Col>
<Grid.Col order={2}>
<Stack spacing={40}>
{/* Contact Card */}
<Skeleton h={520} w='100%' />
{/* Visit Card */}
<Skeleton h={260} w='100%' />
</Stack>
</Grid.Col>
</>
)

const formatNS = nsFormatter(['common', 'services', 'attribute', 'phone-type'])
const useStyles = createStyles((theme) => ({
tabsList: {
position: 'sticky',
Expand All @@ -67,11 +46,7 @@
const router = useRouter<'/org/[slug]'>()
const { data, status } = api.organization.forOrgPage.useQuery({ slug }, { enabled: !!slug })
// const { query } = router
const {
t,
i18n,
ready: i18nReady,
} = useTranslation(['common', 'services', 'attribute', 'phone-type', ...(orgId ? [orgId] : [])])
const { t, i18n, ready: i18nReady } = useTranslation(formatNS(orgId))
const [activeTab, setActiveTab] = useState<string | null>('services')
const [loading, setLoading] = useState(true)
const { data: hasRemote } = api.service.forServiceInfoCard.useQuery(
Expand All @@ -94,15 +69,14 @@
const reviewsRef = useRef<HTMLDivElement>(null)

useEffect(() => {
if (i18nReady && data && status === 'success') {
if (i18nReady && data && status === 'success' && !router.isFallback) {
setLoading(false)
if (data.locations?.length > 1) setActiveTab('locations')
}
}, [data, status, i18nReady])
}, [data, status, i18nReady, router.isFallback])

useEffect(() => {
data?.id &&
i18n.reloadResources(i18n.resolvedLanguage, ['common', 'services', 'attribute', 'phone-type', data.id])
orgId && i18n.reloadResources(i18n.resolvedLanguage, formatNS(orgId))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

Expand Down Expand Up @@ -287,9 +261,7 @@
if (!orgId) return { notFound: true }

const [i18n] = await Promise.allSettled([
orgId
? getServerSideTranslations(locale, ['common', 'services', 'attribute', 'phone-type', orgId])
: getServerSideTranslations(locale, ['common', 'services', 'attribute', 'phone-type']),
getServerSideTranslations(locale, formatNS(orgId)),
ssg.organization.forOrgPage.prefetch({ slug }),
])
// await ssg.organization.getBySlug.prefetch({ slug })
Expand Down
2 changes: 0 additions & 2 deletions apps/app/src/utils/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import { type Namespaces } from '@weareinreach/db/generated/namespaces'

import i18nextConfig from '../../next-i18next.config.mjs'

// type ArrayElementOrSelf<T> = T extends Array<infer U> ? U[] : T[]

type Namespace = LiteralUnion<Namespaces, string>
type NamespaceSSR = string | string[] | undefined
export const getServerSideTranslations = async (
Expand Down
12 changes: 12 additions & 0 deletions apps/app/src/utils/nsFormatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import compact from 'just-compact'
import { type LiteralUnion } from 'type-fest'

import { type Namespaces } from '@weareinreach/db/generated/namespaces'

type Namespace = LiteralUnion<Namespaces, string>
export const nsFormatter =
(baseNamespaces: Namespace | Namespace[]) => (additionalNamespaces?: string | string[]) =>
compact<Namespace>([
...(Array.isArray(baseNamespaces) ? baseNamespaces : [baseNamespaces]),
...(Array.isArray(additionalNamespaces) ? additionalNamespaces : [additionalNamespaces]),
])
2 changes: 1 addition & 1 deletion packages/api/trpc/ssr.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createServerSideHelpers } from '@trpc/react-query/server'
import { type GetServerSidePropsContext, type NextApiRequest, type NextApiResponse } from 'next'

import { getServerSession } from '@weareinreach/auth/next-auth/get-session'
import { getServerSession } from '@weareinreach/auth/next-auth/session.server'
import { type Session } from '@weareinreach/auth/next-auth/types'
import { transformer } from '@weareinreach/util/transformer'
import { createContextInner } from '~api/lib/context'
Expand Down
1 change: 1 addition & 0 deletions packages/auth/index.browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { checkPermissions } from './next-auth/session.browser'
5 changes: 2 additions & 3 deletions packages/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
/* eslint-disable import/no-unused-modules */
import { type DefaultSession, type DefaultUser, type User } from 'next-auth/core/types'
import { type DefaultJWT } from 'next-auth/jwt'

export { getServerSession, checkPermissions, checkServerPermissions } from './next-auth/get-session'

export { getServerSession, checkServerPermissions } from './next-auth/session.server'
export { checkPermissions } from './next-auth/session.browser'
export type { Session } from 'next-auth'

export * from './lib'
Expand Down
18 changes: 18 additions & 0 deletions packages/auth/next-auth/session.browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { type Session } from 'next-auth'

import { type Permission } from '@weareinreach/db/generated/permission'

export interface CheckPermissionsParams {
session: Session | null
permissions: Permission | Permission[]
has: 'all' | 'some'
}
export const checkPermissions = ({ session, permissions, has = 'all' }: CheckPermissionsParams) => {
if (!session) return false
if (session.user.permissions.includes('root')) return true
const userPerms = new Set(session.user.permissions)
const permsToCheck = Array.isArray(permissions) ? permissions : [permissions]
return has === 'all'
? permsToCheck.every((perm) => userPerms.has(perm))
: permsToCheck.some((perm) => userPerms.has(perm))
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { type GetServerSidePropsContext, type NextApiRequest, type NextApiResponse } from 'next'
import { getServerSession as nextauthServerSession, type Session } from 'next-auth'

import { type Permission } from '@weareinreach/db/generated/permission'
import { getServerSession as nextauthServerSession } from 'next-auth'

import { authOptions } from './auth-options'
import { checkPermissions, type CheckPermissionsParams } from './session.browser'

interface SSRContext {
req: GetServerSidePropsContext['req']
Expand All @@ -18,20 +17,6 @@ type ServerContext = SSRContext | ApiContext
export const getServerSession = async (ctx: ServerContext) => {
return nextauthServerSession(ctx.req, ctx.res, authOptions)
}
interface CheckPermissionsParams {
session: Session | null
permissions: Permission | Permission[]
has: 'all' | 'some'
}
export const checkPermissions = ({ session, permissions, has = 'all' }: CheckPermissionsParams) => {
if (!session) return false
if (session.user.permissions.includes('root')) return true
const userPerms = new Set(session.user.permissions)
const permsToCheck = Array.isArray(permissions) ? permissions : [permissions]
return has === 'all'
? permsToCheck.every((perm) => userPerms.has(perm))
: permsToCheck.some((perm) => userPerms.has(perm))
}

interface CheckServerPermissions extends Omit<CheckPermissionsParams, 'session'> {
ctx: ServerContext
Expand Down
9 changes: 9 additions & 0 deletions packages/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
"version": "0.100.0",
"private": true,
"license": "GPL-3.0-only",
"exports": {
".": {
"browser": "./index.browser.ts",
"default": "./index.ts"
},
"./*": {
"default": "./*"
}
},
"main": "./index.ts",
"types": "./index.ts",
"scripts": {
Expand Down
45 changes: 42 additions & 3 deletions packages/ui/components/core/UserMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,16 @@ import {
UnstyledButton,
} from '@mantine/core'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/router'
import { signOut, useSession } from 'next-auth/react'
import { useTranslation } from 'next-i18next'

import { checkPermissions } from '@weareinreach/auth'
import { Button } from '~ui/components/core/Button'
import { LangPicker } from '~ui/components/core/LangPicker'
import { Link } from '~ui/components/core/Link'
import { useCustomVariant } from '~ui/hooks/useCustomVariant'
// import { LoginModalLauncher } from '~ui/modals/Login'
// import { SignupModalLauncher } from '~ui/modals/SignUp'

// import { UserAvatar } from './UserAvatar'
// @ts-expect-error Next Dynamic doesn't like polymorphic components
const LoginModalLauncher = dynamic(() =>
import('~ui/modals/LoginSignUp').then((mod) => mod.LoginModalLauncher)
Expand Down Expand Up @@ -70,10 +69,31 @@ const useStyles = createStyles((theme) => ({
export const UserMenu = ({ className, classNames, styles, unstyled }: UserMenuProps) => {
const { t } = useTranslation('common')
const { data: session, status } = useSession()
const router = useRouter()
const { classes, cx } = useStyles(undefined, { name: 'UserMenu', classNames, styles, unstyled })
const variant = useCustomVariant()

const isLoading = status === 'loading'
const canAccessDataPortal = checkPermissions({
session,
permissions: ['dataPortalBasic', 'dataPortalAdmin', 'dataPortalManager'],
has: 'some',
})
const editablePaths: (typeof router.pathname)[] = ['/org/[slug]', '/org/[slug]/[orgLocationId]']
const isEditablePage = editablePaths.includes(router.pathname)
const getEditPathname = (): typeof router.pathname => {
switch (router.pathname) {
case '/org/[slug]': {
return '/org/[slug]/edit'
}
case '/org/[slug]/[orgLocationId]': {
return '/org/[slug]/[orgLocationId]/edit'
}
default: {
return router.pathname
}
}
}

if ((session?.user && status === 'authenticated') || isLoading) {
return (
Expand Down Expand Up @@ -104,6 +124,25 @@ export const UserMenu = ({ className, classNames, styles, unstyled }: UserMenuPr
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
{canAccessDataPortal && (
<>
<Menu.Label>{t('user-menu.admin-options')}</Menu.Label>
<Menu.Item component={Link} href='/admin' target='_self'>
{t('user-menu.data-portal')}
</Menu.Item>
{isEditablePage && (
<Menu.Item
component={Link}
onClick={() => router.replace({ pathname: getEditPathname(), query: router.query })}
target='_self'
>
{t('user-menu.edit-page')}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Label>{t('user-menu.user-options')}</Menu.Label>
</>
)}
<Menu.Item component={Link} href='/account/saved' target='_self'>
{t('words.saved')}
</Menu.Item>
Expand Down
Loading
Loading