diff --git a/src/app/(default)/layout.tsx b/src/app/(default)/layout.tsx index 6a8f7e8c0..bcfa7ed93 100644 --- a/src/app/(default)/layout.tsx +++ b/src/app/(default)/layout.tsx @@ -8,7 +8,6 @@ import { PageFooter } from './components/PageFooter' import { NextAuthProvider } from '@/components/auth/NextAuthProvider' import { ReactToastifyProvider } from './components/ReactToastifyProvider' import { BlockingAlertUploadingInProgress } from './components/ui/GlobalAlerts' -import { OnboardingCheck } from '@/components/auth/OnboardingCheck' export const metadata: Metadata = { title: 'OpenBeta', @@ -36,7 +35,6 @@ export default function RootLayout ({
{children}
- diff --git a/src/app/(maps)/components/ProfileMenu.tsx b/src/app/(maps)/components/ProfileMenu.tsx index 6a4c7627b..e880ab792 100644 --- a/src/app/(maps)/components/ProfileMenu.tsx +++ b/src/app/(maps)/components/ProfileMenu.tsx @@ -3,7 +3,6 @@ import Link from 'next/link' import { SessionProvider } from 'next-auth/react' import { House } from '@phosphor-icons/react/dist/ssr' import AuthenticatedProfileNavButton from '@/components/AuthenticatedProfileNavButton' -import { OnboardingCheck } from '@/components/auth/OnboardingCheck' export const ProfileMenu: React.FC = () => { return ( @@ -14,7 +13,6 @@ export const ProfileMenu: React.FC = () => { - ) } diff --git a/src/pages/api/auth/[...nextauth].ts b/src/app/api/auth/[...nextauth]/route.ts similarity index 86% rename from src/pages/api/auth/[...nextauth].ts rename to src/app/api/auth/[...nextauth]/route.ts index be03eb6cb..efa420bc3 100644 --- a/src/pages/api/auth/[...nextauth].ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -3,8 +3,9 @@ import axios from 'axios' import type { NextAuthOptions } from 'next-auth' import Auth0Provider from 'next-auth/providers/auth0' -import { AUTH_CONFIG_SERVER } from '../../../Config' -import { IUserMetadata, UserRole } from '../../../js/types/User' +import { AUTH_CONFIG_SERVER } from '../../../../Config' +import { IUserMetadata, UserRole } from '../../../../js/types/User' +import { initializeUserInDB } from '@/js/auth/initializeUserInDb' const CustomClaimsNS = 'https://tacos.openbeta.io/' const CustomClaimUserMetadata = CustomClaimsNS + 'user_metadata' @@ -24,7 +25,6 @@ export const authOptions: NextAuthOptions = { clientSecret, issuer, authorization: { params: { audience: 'https://api.openbeta.io', scope: 'offline_access access_token_authz openid email profile read:current_user create:current_user_metadata update:current_user_metadata read:stats update:area_attrs' } }, - client: { token_endpoint_auth_method: clientSecret.length === 0 ? 'none' : 'client_secret_basic' } @@ -84,6 +84,17 @@ export const authOptions: NextAuthOptions = { throw new Error('Invalid auth data') } + if (!(token.userMetadata?.initializedDb ?? false)) { + const { userMetadata, email, picture: avatar, id: auth0UserId } = token + const { nick: username, uuid: userUuid } = userMetadata + const { accessToken } = token + + const success = await initializeUserInDB({ auth0UserId, accessToken, username, userUuid, avatar, email }) + if (success) { + token.userMetadata.initializedDb = true + } + } + if ((token.expiresAt as number) < (Date.now() / 1000)) { const { accessToken, refreshToken, expiresAt } = await refreshAccessTokenSilently(token.refreshToken as string) token.accessToken = accessToken @@ -109,7 +120,8 @@ export const authOptions: NextAuthOptions = { } } -export default NextAuth(authOptions) +const handler = NextAuth(authOptions) +export { handler as GET, handler as POST } const refreshAccessTokenSilently = async (refreshToken: string): Promise => { const response = await axios.request<{ diff --git a/src/app/api/user/me/route.ts b/src/app/api/user/me/route.ts new file mode 100644 index 000000000..6644166ad --- /dev/null +++ b/src/app/api/user/me/route.ts @@ -0,0 +1,33 @@ +import { withUserAuth } from '@/js/auth/withUserAuth' +import { NextRequest, NextResponse } from 'next/server' +import useUserProfileCmd from '@/js/hooks/useUserProfileCmd' + +/** + * Direct `/api/user/me` to `/u/ => { + const uuid = req.headers.get('x-openbeta-user-uuid') + const accessToken = req.headers.get('x-auth0-access-token') + + if (accessToken == null || uuid == null) { + return NextResponse.json({ status: 500 }) + } + + const url = req.nextUrl.clone() + url.pathname = '/' + + try { + const { getUsernameById } = useUserProfileCmd({ accessToken }) + const usernameInfo = await getUsernameById({ userUuid: uuid }) + if (usernameInfo?.username == null) { + return NextResponse.rewrite(url) + } else { + url.pathname = `/u/${usernameInfo.username}` + return NextResponse.redirect(url) + } + } catch (e) { + return NextResponse.rewrite(url) + } +} + +export const GET = withUserAuth(getHandler) diff --git a/src/components/auth/OnboardingCheck.tsx b/src/components/auth/OnboardingCheck.tsx deleted file mode 100644 index 08d28c74b..000000000 --- a/src/components/auth/OnboardingCheck.tsx +++ /dev/null @@ -1,14 +0,0 @@ -'use client' -import useUsernameCheck, { useUsernameCheckAppDir } from '@/js/hooks/useUsernameCheck' - -/** - * A wrapper component to imperatively check if the user has completed onboarding. - */ -export const OnboardingCheck: React.FC<{ isAppDir?: boolean }> = ({ isAppDir = true }) => { - if (isAppDir) { - useUsernameCheckAppDir() - } else { - useUsernameCheck() - } - return null -} diff --git a/src/js/auth/initializeUserInDb.ts b/src/js/auth/initializeUserInDb.ts new file mode 100644 index 000000000..d4911741a --- /dev/null +++ b/src/js/auth/initializeUserInDb.ts @@ -0,0 +1,44 @@ +import { getClient } from '../graphql/ServerClient' +import { MUTATION_UPDATE_PROFILE } from '../graphql/gql/users' +import { updateUser } from './ManagementClient' + +export interface UpdateUsernameInput { + userUuid: string + username: string + email?: string | null + avatar?: string | null +} + +interface InitializeUserInDBParams extends UpdateUsernameInput { + accessToken: string + auth0UserId: string +} + +export const initializeUserInDB = async (params: InitializeUserInDBParams): Promise => { + const { auth0UserId, accessToken, userUuid, username, email, avatar } = params + const res = await getClient().mutate<{ updateUserProfile?: boolean }, UpdateUsernameInput>({ + mutation: MUTATION_UPDATE_PROFILE, + variables: { + userUuid, + username, + email, + avatar + }, + context: { + headers: { + authorization: `Bearer ${accessToken}` + } + }, + fetchPolicy: 'no-cache' + }) + const success = res.data?.updateUserProfile ?? false + if (success) { + try { + await updateUser(auth0UserId, { initializedDb: true }) + } catch (error) { + console.log('Error initializing user in db') + return false + } + } + return success +} diff --git a/src/js/auth/withUserAuth.ts b/src/js/auth/withUserAuth.ts index dce265384..04e078bca 100644 --- a/src/js/auth/withUserAuth.ts +++ b/src/js/auth/withUserAuth.ts @@ -1,6 +1,6 @@ import { getServerSession } from 'next-auth' import { NextRequest, NextResponse } from 'next/server' -import { authOptions } from 'pages/api/auth/[...nextauth]' +import { authOptions } from '@/app/api/auth/[...nextauth]/route' type Next13APIHandler = (req: NextRequest) => Promise @@ -15,6 +15,7 @@ export const withUserAuth = (handler: Next13APIHandler): Next13APIHandler => { // Passing useful session data downstream req.headers.set('x-openbeta-user-uuid', session.user.metadata.uuid) req.headers.set('x-auth0-userid', session.id) + req.headers.set('x-auth0-access-token', session.accessToken) return await handler(req) } else { return NextResponse.json({ status: 401 }) diff --git a/src/js/hooks/useUserProfileCmd.tsx b/src/js/hooks/useUserProfileCmd.tsx index 62ff83164..73847c907 100644 --- a/src/js/hooks/useUserProfileCmd.tsx +++ b/src/js/hooks/useUserProfileCmd.tsx @@ -9,7 +9,7 @@ import { UserPublicProfile } from '../types/User' interface GetUsernameByIdInput { userUuid: string } -interface UpdateUsernameInput { +export interface UpdateUsernameInput { userUuid: string username: string email?: string diff --git a/src/js/hooks/useUsernameCheck.ts b/src/js/hooks/useUsernameCheck.ts deleted file mode 100644 index a44db2564..000000000 --- a/src/js/hooks/useUsernameCheck.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useEffect } from 'react' -import { useRouter as useRouterAppDir, usePathname } from 'next/navigation' -import { useRouter } from 'next/router' -import { useSession } from 'next-auth/react' -import useUserProfileCmd from './useUserProfileCmd' - -/** - * A global hook that forces new users to go to the user name screen. - */ -export default function useUsernameCheck (): void { - const router = useRouter() - const { data, status } = useSession() - const { getUsernameById } = useUserProfileCmd({ accessToken: data?.accessToken as string }) - - useEffect(() => { - const uuid = data?.user.metadata.uuid - if (router.asPath.startsWith('/auth/')) { - return - } - if (status === 'authenticated' && uuid != null) { - void getUsernameById({ userUuid: uuid }).then(usernameInfo => { - if (usernameInfo === null) { - void router.push('/account/changeUsername') - } - }) - } - }, [status]) -} - -/** - * A global hook that forces new users to go to the user name screen. Use this version for pages in the `/app` directory (Next v13). - */ -export function useUsernameCheckAppDir (): void { - const router = useRouterAppDir() - const pathname = usePathname() - const { data, status } = useSession() - const { getUsernameById } = useUserProfileCmd({ accessToken: data?.accessToken as string }) - - useEffect(() => { - const uuid = data?.user.metadata.uuid - if (pathname.startsWith('/auth/')) { - return - } - if (status === 'authenticated' && uuid != null) { - void getUsernameById({ userUuid: uuid }).then(usernameInfo => { - if (usernameInfo === null) { - void router.push('/account/changeUsername') - } - }) - } - }, [status]) -} diff --git a/src/js/types/User.ts b/src/js/types/User.ts index cbf48f8e6..4564cefc9 100644 --- a/src/js/types/User.ts +++ b/src/js/types/User.ts @@ -22,6 +22,10 @@ export interface IWritableUserMetadata { bio: string website?: string ticksImported?: boolean + /** + * Indicate whether or not we have initialized user account in our own db + */ + initializedDb?: boolean collections?: { /** Users can organize entities into their own 'climbing playlists' * Strictly speaking, this should always be mutated as a SET rather than an diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 66b90f2ed..9c1b7dc4c 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -13,7 +13,6 @@ import '../../public/fonts/fonts.css' import { useUserGalleryStore } from '../js/stores/useUserGalleryStore' import { BlockingAlert } from '../components/ui/micro/AlertDialogue' import { XMarkIcon } from '@heroicons/react/24/outline' -import { OnboardingCheck } from '@/components/auth/OnboardingCheck' Router.events.on('routeChangeStart', () => NProgress.start()) Router.events.on('routeChangeComplete', () => NProgress.done()) @@ -42,7 +41,6 @@ export default function MyApp ({ Component, pageProps: { session, ...pageProps } ) } - = async (req, res) => { try { diff --git a/src/pages/api/basecamp/userRoles.ts b/src/pages/api/basecamp/userRoles.ts index 806cb4bde..e089c7f57 100644 --- a/src/pages/api/basecamp/userRoles.ts +++ b/src/pages/api/basecamp/userRoles.ts @@ -4,7 +4,7 @@ import { getServerSession } from 'next-auth' import withAuth from '../withAuth' import { getUserRoles, setUserRoles } from '../../../js/auth/ManagementClient' import { UserRole } from '../../../js/types' -import { authOptions } from '../auth/[...nextauth]' +import { authOptions } from '@/app/api/auth/[...nextauth]/route' const handler: NextApiHandler = async (req, res) => { try { diff --git a/src/pages/api/basecamp/users.ts b/src/pages/api/basecamp/users.ts index 48f829414..2b61036d3 100644 --- a/src/pages/api/basecamp/users.ts +++ b/src/pages/api/basecamp/users.ts @@ -4,7 +4,7 @@ import { getServerSession } from 'next-auth' import withAuth from '../withAuth' import { getAllUsersMetadata } from '../../../js/auth/ManagementClient' import { UserRole } from '../../../js/types' -import { authOptions } from '../auth/[...nextauth]' +import { authOptions } from '@/app/api/auth/[...nextauth]/route' const handler: NextApiHandler = async (req, res) => { try { diff --git a/src/pages/api/media/get-signed-url.ts b/src/pages/api/media/get-signed-url.ts index daab26321..c6afa0674 100644 --- a/src/pages/api/media/get-signed-url.ts +++ b/src/pages/api/media/get-signed-url.ts @@ -4,7 +4,7 @@ import { nolookalikesSafe } from 'nanoid-dictionary' import { extname } from 'path' import { getServerSession } from 'next-auth' import withAuth from '../withAuth' -import { authOptions } from '../auth/[...nextauth]' +import { authOptions } from '@/app/api/auth/[...nextauth]/route' import { getSignedUrlForUpload } from '../../../js/media/storageClient' export interface MediaPreSignedProps { diff --git a/src/pages/api/user/me.ts b/src/pages/api/user/me.ts deleted file mode 100644 index 989a89ea8..000000000 --- a/src/pages/api/user/me.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { NextApiHandler } from 'next' -import { getServerSession } from 'next-auth' -import { authOptions } from '../auth/[...nextauth]' - -import withAuth from '../withAuth' -import useUserProfileCmd from '../../../js/hooks/useUserProfileCmd' - -const handler: NextApiHandler = async (req, res) => { - const session = await getServerSession(req, res, authOptions) - const uuid = session?.user.metadata.uuid - - if (uuid == null) { - res.writeHead(307, { Location: '/' }).end() - return - } - - const isPreview = req.query?.preview != null - - try { - const { getUsernameById } = useUserProfileCmd({ accessToken: uuid }) - const usernameInfo = await getUsernameById({ userUuid: uuid }) - if (usernameInfo?.username == null) { - res.writeHead(307, { Location: '/' }).end() - } else { - const Location = isPreview ? `/u2/${usernameInfo.username}` : `/u/${usernameInfo.username}` - res.writeHead(307, { Location }).end() - } - } catch (e) { - res.writeHead(307, { Location: '/' }).end() - } -} - -export default withAuth(handler) diff --git a/src/pages/api/withAuth.ts b/src/pages/api/withAuth.ts index 42aa78e81..242462379 100644 --- a/src/pages/api/withAuth.ts +++ b/src/pages/api/withAuth.ts @@ -4,7 +4,7 @@ */ import { NextApiHandler } from 'next' import { getServerSession } from 'next-auth' -import { authOptions } from './auth/[...nextauth]' +import { authOptions } from '@/app/api/auth/[...nextauth]/route' const withAuth = (handler: NextApiHandler): NextApiHandler => { return async (req, res) => {