From 6789079aeed86f5c96566ef5972933f169ce3eb2 Mon Sep 17 00:00:00 2001 From: GeekyAdams Date: Wed, 28 Jun 2023 21:05:12 +0100 Subject: [PATCH 01/68] feat: Link from dashboard to extension docs #15415 --- .../Database/Extensions/ExtensionCard.tsx | 31 ++++++++++++++----- studio/pages/project/[ref]/index.tsx | 9 +++--- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx b/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx index f7f3337f962..633fa5acd99 100644 --- a/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx +++ b/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx @@ -1,7 +1,8 @@ import { FC, useState } from 'react' import { observer } from 'mobx-react-lite' -import { Badge, IconLoader, Toggle } from 'ui' +import { Badge, IconLoader, Toggle, IconExternalLink } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' +import Link from 'next/link' import { checkPermissions, useStore } from 'hooks' import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal' @@ -69,15 +70,29 @@ const ExtensionCard: FC = ({ extension }) => {
-

- {extension.name} -

+ +
+

+ {extension.name} +

+ + +
+
+ + {loading ? ( ) : ( @@ -100,7 +115,7 @@ const ExtensionCard: FC = ({ extension }) => {
{isOn && extension.schema && (
-
+
Schema: {`${extension.schema}`}
diff --git a/studio/pages/project/[ref]/index.tsx b/studio/pages/project/[ref]/index.tsx index f380381c48c..830dd12418b 100644 --- a/studio/pages/project/[ref]/index.tsx +++ b/studio/pages/project/[ref]/index.tsx @@ -1,3 +1,4 @@ +import ExtensionCard from 'components/interfaces/Database/Extensions/ExtensionCard' import { ClientLibrary, ExampleProject } from 'components/interfaces/Home' import { CLIENT_LIBRARIES, EXAMPLE_PROJECTS } from 'components/interfaces/Home/Home.constants' import ProjectUsageSection from 'components/interfaces/Home/ProjectUsageSection' @@ -18,8 +19,8 @@ const Home: NextPageWithLayout = () => { : 'Welcome to your project' return ( -
-
+
+

{projectName}

@@ -41,7 +42,7 @@ const Home: NextPageWithLayout = () => {

Client libraries

-
+
{CLIENT_LIBRARIES.map((library) => ( ))} @@ -51,7 +52,7 @@ const Home: NextPageWithLayout = () => {

Example projects

-
+
{EXAMPLE_PROJECTS.sort((a, b) => a.title.localeCompare(b.title)).map((project) => ( ))} From d6be833e0f1443e8f9e742e09be0930421b2b68e Mon Sep 17 00:00:00 2001 From: GeekyAdams Date: Wed, 28 Jun 2023 21:08:54 +0100 Subject: [PATCH 02/68] feat: Link from dashboard to extension docs #15415 --- studio/pages/project/[ref]/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/studio/pages/project/[ref]/index.tsx b/studio/pages/project/[ref]/index.tsx index 830dd12418b..63269ee9258 100644 --- a/studio/pages/project/[ref]/index.tsx +++ b/studio/pages/project/[ref]/index.tsx @@ -1,4 +1,3 @@ -import ExtensionCard from 'components/interfaces/Database/Extensions/ExtensionCard' import { ClientLibrary, ExampleProject } from 'components/interfaces/Home' import { CLIENT_LIBRARIES, EXAMPLE_PROJECTS } from 'components/interfaces/Home/Home.constants' import ProjectUsageSection from 'components/interfaces/Home/ProjectUsageSection' From f30a7181efb57cbfb7ff28a07c56271289f7d599 Mon Sep 17 00:00:00 2001 From: Terry Sutton Date: Fri, 30 Jun 2023 11:29:28 -0230 Subject: [PATCH 03/68] Update studio/components/interfaces/Database/Extensions/ExtensionCard.tsx --- .../components/interfaces/Database/Extensions/ExtensionCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx b/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx index 633fa5acd99..9f0397540e1 100644 --- a/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx +++ b/studio/components/interfaces/Database/Extensions/ExtensionCard.tsx @@ -88,7 +88,7 @@ const ExtensionCard: FC = ({ extension }) => { {extension.name} - +
From 777c5b407a6ee1fbd8dee15a8219d63a81248e45 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Fri, 25 Aug 2023 14:24:14 +0200 Subject: [PATCH 04/68] Add a modal for adding new factor. --- .../interfaces/Account/AddNewFactorModal.tsx | 201 ++++++++++++++++++ studio/components/interfaces/Account/index.ts | 5 +- studio/components/ui/ConfirmationModal.tsx | 4 +- studio/data/profile/keys.ts | 1 + .../mfa-challenge-and-verify-mutation.ts | 32 +++ studio/data/profile/mfa-enroll-mutation.ts | 22 ++ studio/data/profile/mfa-list-factors-query.ts | 31 +++ studio/data/profile/mfa-unenroll-mutation.ts | 33 +++ 8 files changed, 326 insertions(+), 3 deletions(-) create mode 100644 studio/components/interfaces/Account/AddNewFactorModal.tsx create mode 100644 studio/data/profile/mfa-challenge-and-verify-mutation.ts create mode 100644 studio/data/profile/mfa-enroll-mutation.ts create mode 100644 studio/data/profile/mfa-list-factors-query.ts create mode 100644 studio/data/profile/mfa-unenroll-mutation.ts diff --git a/studio/components/interfaces/Account/AddNewFactorModal.tsx b/studio/components/interfaces/Account/AddNewFactorModal.tsx new file mode 100644 index 00000000000..822b59b3b11 --- /dev/null +++ b/studio/components/interfaces/Account/AddNewFactorModal.tsx @@ -0,0 +1,201 @@ +import ConfirmationModal from 'components/ui/ConfirmationModal' +import InformationBox from 'components/ui/InformationBox' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useMfaChallengeAndVerifyMutation } from 'data/profile/mfa-challenge-and-verify-mutation' +import { useMfaEnrollMutation } from 'data/profile/mfa-enroll-mutation' +import { useMfaUnenrollMutation } from 'data/profile/mfa-unenroll-mutation' +import { useStore } from 'hooks' +import Image from 'next/image' +import { Dispatch, SetStateAction, useEffect, useState } from 'react' +import { Input, Modal } from 'ui' + +const AddNewFactorModal = ({ onClose }: { onClose: () => void }) => { + const [verificationComplete, setVerificationComplete] = useState(false) + // Generate a name with a number between 0 and 1000 + const [name, setName] = useState(`App ${Math.floor(Math.random() * 1000)}`) + + const { + mutate: enroll, + data, + reset: resetEnrollment, + isLoading: isEnrolling, + } = useMfaEnrollMutation() + const { mutate: unenroll } = useMfaUnenrollMutation() + + useEffect(() => { + return () => { + // when the modal is closed, if there's a factor id which hasn't been verified, unenroll it + if (data?.id && !verificationComplete) { + // delete the internal state of enroll mutation so that the next time enroll is called, + // it makes an API call + resetEnrollment() + unenroll({ factorId: data.id }) + } + } + }, []) + + return !data ? ( + + ) : ( + { + setVerificationComplete(true) + onClose() + }} + onClose={onClose} + /> + ) +} + +const FirstStep = ({ + name, + setName, + enroll, + onClose, +}: { + name: string + setName: Dispatch> + enroll: (params: { factorType: 'totp'; friendlyName?: string }) => void + onClose: () => void +}) => { + return ( + { + enroll({ + factorType: 'totp', + friendlyName: name, + }) + }} + > + + <> +
+ setName(e.target.value)} + /> +
+ +
+
+ ) +} + +const SecondStep = ({ + factorName, + factor, + isLoading, + onSuccess, + onClose, +}: { + factorName: string + factor: { + id: string + type: 'totp' + totp: { + qr_code: string + secret: string + uri: string + } + } + isLoading: boolean + onSuccess: () => void + onClose: () => void +}) => { + const [code, setCode] = useState('') + + const { ui } = useStore() + + const { + mutate: challengeAndVerify, + isLoading: isVerifying, + isSuccess: verificationComplete, + } = useMfaChallengeAndVerifyMutation({ + onError: (error) => { + ui.setNotification({ + category: 'error', + message: `Failed to add a second factor authentication: ${error?.message}`, + }) + }, + onSuccess: () => { + ui.setNotification({ + category: 'success', + message: `Successfully added a second factor authentication`, + }) + onSuccess() + }, + }) + + return ( + challengeAndVerify({ factorId: factor.id, code })} + > + + <> +
+ + Use an authenticator app to scan the following QR code, and provide the code from the + app to complete the enrolment. + +
+ {isLoading ? ( +
+ +
+ ) : ( + <> +
+
+ {factor.totp.uri} +
+
+
+ + } + /> +
+
+ setCode(e.target.value)} + /> +
+ + )} + +
+
+ ) +} + +export default AddNewFactorModal diff --git a/studio/components/interfaces/Account/index.ts b/studio/components/interfaces/Account/index.ts index 4ce89542872..38c906b34f7 100644 --- a/studio/components/interfaces/Account/index.ts +++ b/studio/components/interfaces/Account/index.ts @@ -1,5 +1,6 @@ import AccessTokenList from './AccessTokenList' -import NewTokenBanner from './NewTokenBanner' +import AddNewFactorModal from './AddNewFactorModal' import NewAccessTokenButton from './NewAccessTokenButton' +import NewTokenBanner from './NewTokenBanner' -export { AccessTokenList, NewTokenBanner, NewAccessTokenButton } +export { AccessTokenList, NewTokenBanner, NewAccessTokenButton, AddNewFactorModal } diff --git a/studio/components/ui/ConfirmationModal.tsx b/studio/components/ui/ConfirmationModal.tsx index 842572933c0..940fdafd810 100644 --- a/studio/components/ui/ConfirmationModal.tsx +++ b/studio/components/ui/ConfirmationModal.tsx @@ -10,6 +10,7 @@ export interface ConfirmationModalProps { size?: 'small' | 'tiny' | 'medium' | 'large' buttonLabel: string buttonLoadingLabel?: string + buttonDisabled?: boolean onSelectCancel: () => void onSelectConfirm: () => void } @@ -23,6 +24,7 @@ const ConfirmationModal = ({ size = 'small', buttonLabel = '', buttonLoadingLabel = '', + buttonDisabled = false, onSelectCancel = () => {}, onSelectConfirm = () => {}, children, @@ -62,7 +64,7 @@ const ConfirmationModal = ({
+ ) +} + +Security.getLayout = (page) => ( + + {page} + +) + +export default Security + +const ProfileCard = () => { + const [isAddNewFactorOpen, setIsAddNewFactorOpen] = useState(false) + const [factorToBeDeleted, setFactorToBeDeleted] = useState(null) + const { data, isLoading, isError, isSuccess, error } = useMfaListFactorsQuery() + + return ( + <> +
+
+ + Generate one-time passwords via authenticator apps like 1Password, Authy, etc. as a + second factor to verify your identity when prompted during sign-in. + +
+ {isLoading && ( +
+ {[1, 2].map((number) => { + return + })} +
+ )} + {isError && ( +
+ +
+ )} + {isSuccess && ( + <> +
+ {data.totp.map((factor) => { + return ( +
+
+ Factor ID: {factor.id} + + Name: {factor.friendly_name ?? 'No name provided'} + +
+
+ + Created at {new Date(factor.updated_at).toLocaleString()} + +
+
+ ) + })} +
+ {data.totp.length < 2 ? ( + <> +
+ +
+ + ) : null} + + )} +
+ {isAddNewFactorOpen && setIsAddNewFactorOpen(false)} />} + {factorToBeDeleted && ( + setFactorToBeDeleted(null)} + /> + )} + + ) +} + +const DeleteFactorModal = ({ + factorId, + lastFactorToBeDeleted, + onClose, +}: { + factorId: string + lastFactorToBeDeleted: boolean + onClose: () => void +}) => { + const { ui } = useStore() + + const { mutate: unenroll, isLoading } = useMfaUnenrollMutation({ + onError: (error) => { + ui.setNotification({ + category: 'error', + message: `Failed to delete a second factor authentication: ${error?.message}`, + }) + }, + onSuccess: () => { + ui.setNotification({ + category: 'success', + message: `Successfully deleted a second factor authentication.`, + }) + onClose() + }, + }) + + return ( + { + unenroll({ factorId }) + }} + > + + + + + {lastFactorToBeDeleted + ? 'Multi-factor authentication will be disabled' + : 'This action cannot be undone'} + + + {lastFactorToBeDeleted + ? 'There are no other factors that are set up once you delete this factor, as such your account will no longer be guarded by multi-factor authentication' + : 'You will no longer be able to use this authenticator app for multi-factor authentication when signing in to the dashboard'} + + +
+ Before deleting this factor, consider: +
    + {lastFactorToBeDeleted ? ( + <> +
  • Adding another authenticator app as a factor prior to deleting
  • +
  • Ensure that your account does not need multi-factor authentication
  • + + ) : ( + <> +
  • Your backup authenticator app is still available to use
  • +
  • Adding another authenticator app thereafter as a backup
  • + + )} +
+
+
+
+ ) +} From 5aac02b441af446f01dd575fc8e39b9a39e2ddb9 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Fri, 25 Aug 2023 14:26:02 +0200 Subject: [PATCH 06/68] Simplify the sign-in page. --- studio/pages/sign-in.tsx | 28 +++++----------------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/studio/pages/sign-in.tsx b/studio/pages/sign-in.tsx index 39270ebdf65..802b60eed2d 100644 --- a/studio/pages/sign-in.tsx +++ b/studio/pages/sign-in.tsx @@ -1,37 +1,19 @@ import Link from 'next/link' -import { NextPageWithLayout } from 'types' import SignInForm from 'components/interfaces/SignIn/SignInForm' import SignInWithGitHub from 'components/interfaces/SignIn/SignInWithGitHub' import SignInWithSSO from 'components/interfaces/SignIn/SignInWithSSO' import { SignInLayout } from 'components/layouts' -import { useEffect } from 'react' import { IS_PLATFORM } from 'lib/constants' import { useRouter } from 'next/router' -import { auth } from 'lib/gotrue' +import { NextPageWithLayout } from 'types' const SignInPage: NextPageWithLayout = () => { const router = useRouter() - useEffect(() => { - // if the dashboard is running locally, redirect straight to the projects page - if (!IS_PLATFORM) { - router.replace('/project/default') - return - } - - const { - data: { subscription }, - } = auth.onAuthStateChange((event) => { - if (event === 'SIGNED_IN') { - // if user has signed in on another tab, take them to the home page - router.replace('/projects') - } - }) - - return () => { - subscription.unsubscribe() - } - }, []) + if (!IS_PLATFORM) { + // on selfhosted instance just redirect to projects page + router.replace('/project/default') + } return ( <> From 7b5183016ea902371689db0444dd90e51969e52d Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Fri, 25 Aug 2023 14:42:05 +0200 Subject: [PATCH 07/68] Add a page for signing in via MFA. --- .../interfaces/SignIn/SignInMfaForm.tsx | 124 ++++++++++++++++++ studio/data/profile/keys.ts | 1 + ...mfa-authenticator-assurance-level-query.ts | 41 ++++++ .../mfa-challenge-and-verify-mutation.ts | 7 +- studio/data/profile/mfa-unenroll-mutation.ts | 7 +- studio/pages/sign-in-mfa.tsx | 25 ++++ 6 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 studio/components/interfaces/SignIn/SignInMfaForm.tsx create mode 100644 studio/data/profile/mfa-authenticator-assurance-level-query.ts create mode 100644 studio/pages/sign-in-mfa.tsx diff --git a/studio/components/interfaces/SignIn/SignInMfaForm.tsx b/studio/components/interfaces/SignIn/SignInMfaForm.tsx new file mode 100644 index 00000000000..7647c14b266 --- /dev/null +++ b/studio/components/interfaces/SignIn/SignInMfaForm.tsx @@ -0,0 +1,124 @@ +import { AuthError } from '@supabase/gotrue-js' +import { Factor } from '@supabase/supabase-js' +import { useQueryClient } from '@tanstack/react-query' +import { mfaChallengeAndVerify } from 'data/profile/mfa-challenge-and-verify-mutation' +import { useMfaListFactorsQuery } from 'data/profile/mfa-list-factors-query' +import { useStore } from 'hooks' +import { usePushNext } from 'hooks/misc/useAutoAuthRedirect' +import { useEffect, useState } from 'react' +import { Button, Form, IconLock, Input } from 'ui' +import { object, string } from 'yup' + +const signInSchema = object({ + code: string().required('MFA Code is required'), +}) + +const SignInMfaForm = () => { + const { ui } = useStore() + const pushNext = usePushNext() + const queryClient = useQueryClient() + const { data: factors, isSuccess: factorsSuccess } = useMfaListFactorsQuery() + const [selectedFactor, setSelectedFactor] = useState(null) + + // if the user wanders into this page and he has no MFA setup, send the user to the next screen + if (factorsSuccess && factors.totp.length === 0) { + queryClient.resetQueries().then(() => pushNext()) + } + + useEffect(() => { + if (factorsSuccess) { + setSelectedFactor(factors.totp[0]) + } + }, [factors?.totp, factorsSuccess]) + + const onSignIn = async ({ code }: { code: string }) => { + const toastId = ui.setNotification({ + category: 'loading', + message: `Signing in...`, + }) + if (selectedFactor) { + try { + const result = await mfaChallengeAndVerify({ factorId: selectedFactor.id, code }) + + ui.setNotification({ + id: toastId, + category: 'success', + message: `Signed in successfully!`, + }) + + await queryClient.resetQueries() + + await pushNext() + } catch (error: unknown) { + ui.setNotification({ + id: toastId, + category: 'error', + message: (error as AuthError).message, + }) + } + } + } + + return ( +
+ {({ isSubmitting }: { isSubmitting: boolean }) => { + return ( +
+ } + placeholder="XXXXXX" + disabled={isSubmitting} + autoComplete="off" + spellCheck="false" + autoCapitalize="none" + autoCorrect="off" + label={ + selectedFactor && factors?.totp.length === 2 + ? `Code generated by ${selectedFactor.friendly_name}` + : null + } + /> + + + {factors?.totp.length === 2 && ( + + )} +
+ ) + }} +
+ ) +} + +export default SignInMfaForm diff --git a/studio/data/profile/keys.ts b/studio/data/profile/keys.ts index a43a33f7a73..2ed0d876185 100644 --- a/studio/data/profile/keys.ts +++ b/studio/data/profile/keys.ts @@ -1,4 +1,5 @@ export const profileKeys = { profile: () => ['profile'] as const, mfaFactors: () => ['mfa', 'factors'] as const, + aaLevel: () => ['mfa', 'aaLevel'] as const, } diff --git a/studio/data/profile/mfa-authenticator-assurance-level-query.ts b/studio/data/profile/mfa-authenticator-assurance-level-query.ts new file mode 100644 index 00000000000..f5b2ae77113 --- /dev/null +++ b/studio/data/profile/mfa-authenticator-assurance-level-query.ts @@ -0,0 +1,41 @@ +import { AuthMFAGetAuthenticatorAssuranceLevelResponse } from '@supabase/supabase-js' +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { auth } from 'lib/gotrue' +import { profileKeys } from './keys' +import { Profile } from './types' + +export type ProfileResponse = Profile + +export async function getMfaAuthenticatorAssuranceLevel() { + const { error, data } = await auth.mfa.getAuthenticatorAssuranceLevel() + + if (error) throw error + return data +} + +type CustomAuthMFAGetAuthenticatorAssuranceLevelData = NonNullable< + AuthMFAGetAuthenticatorAssuranceLevelResponse['data'] +> +type CustomAuthMFAGetAuthenticatorAssuranceLevelError = NonNullable< + AuthMFAGetAuthenticatorAssuranceLevelResponse['error'] +> + +export const useAuthenticatorAssuranceLevelQuery = < + TData = CustomAuthMFAGetAuthenticatorAssuranceLevelData +>({ + enabled = true, + ...options +}: UseQueryOptions< + CustomAuthMFAGetAuthenticatorAssuranceLevelData, + CustomAuthMFAGetAuthenticatorAssuranceLevelError, + TData +> = {}) => { + return useQuery< + CustomAuthMFAGetAuthenticatorAssuranceLevelData, + CustomAuthMFAGetAuthenticatorAssuranceLevelError, + TData + >(profileKeys.aaLevel(), () => getMfaAuthenticatorAssuranceLevel(), { + staleTime: 1000 * 60 * 30, // default good for 30 mins + ...options, + }) +} diff --git a/studio/data/profile/mfa-challenge-and-verify-mutation.ts b/studio/data/profile/mfa-challenge-and-verify-mutation.ts index 59d3c7ad6c1..21d8c721f8a 100644 --- a/studio/data/profile/mfa-challenge-and-verify-mutation.ts +++ b/studio/data/profile/mfa-challenge-and-verify-mutation.ts @@ -24,7 +24,12 @@ export const useMfaChallengeAndVerifyMutation = ({ return useMutation((vars) => mfaChallengeAndVerify(vars), { async onSuccess(data, variables, context) { - await queryClient.invalidateQueries(profileKeys.mfaFactors()) + // when a MFA is added, the aaLevel is bumped up + await Promise.all([ + queryClient.invalidateQueries(profileKeys.mfaFactors()), + queryClient.invalidateQueries(profileKeys.aaLevel()), + ]) + await onSuccess?.(data, variables, context) }, ...options, diff --git a/studio/data/profile/mfa-unenroll-mutation.ts b/studio/data/profile/mfa-unenroll-mutation.ts index 0d8bf7ff41e..903efb9f98b 100644 --- a/studio/data/profile/mfa-unenroll-mutation.ts +++ b/studio/data/profile/mfa-unenroll-mutation.ts @@ -25,7 +25,12 @@ export const useMfaUnenrollMutation = ({ return useMutation((vars) => mfaUnenroll(vars), { async onSuccess(data, variables, context) { - await queryClient.invalidateQueries(profileKeys.mfaFactors()) + // when a factor is unenrolled, the aaLevel is bumped down if it's the last factor + await Promise.all([ + queryClient.invalidateQueries(profileKeys.mfaFactors()), + queryClient.invalidateQueries(profileKeys.aaLevel()), + ]) + await onSuccess?.(data, variables, context) }, ...options, diff --git a/studio/pages/sign-in-mfa.tsx b/studio/pages/sign-in-mfa.tsx new file mode 100644 index 00000000000..aaafe13a6a7 --- /dev/null +++ b/studio/pages/sign-in-mfa.tsx @@ -0,0 +1,25 @@ +import SignInMfaForm from 'components/interfaces/SignIn/SignInMfaForm' +import { SignInLayout } from 'components/layouts' +import { NextPageWithLayout } from 'types' + +const SignInMfaPage: NextPageWithLayout = () => { + return ( + <> +
+ +
+ + ) +} + +SignInMfaPage.getLayout = (page) => ( + + {page} + +) + +export default SignInMfaPage From 7ac94601041b9f0fc718bb7068bfb5162ed00fac Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Fri, 25 Aug 2023 14:42:44 +0200 Subject: [PATCH 08/68] Redirect to MFA if the user is logging in via email and has MFA setup. --- .../interfaces/SignIn/SignInForm.tsx | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/studio/components/interfaces/SignIn/SignInForm.tsx b/studio/components/interfaces/SignIn/SignInForm.tsx index 934b401e21e..c58d2351751 100644 --- a/studio/components/interfaces/SignIn/SignInForm.tsx +++ b/studio/components/interfaces/SignIn/SignInForm.tsx @@ -1,14 +1,17 @@ import HCaptcha from '@hcaptcha/react-hcaptcha' +import * as Sentry from '@sentry/nextjs' +import { AuthError } from '@supabase/supabase-js' import { useQueryClient } from '@tanstack/react-query' +import { getMfaAuthenticatorAssuranceLevel } from 'data/profile/mfa-authenticator-assurance-level-query' import { useStore } from 'hooks' import { usePushNext } from 'hooks/misc/useAutoAuthRedirect' import { auth } from 'lib/gotrue' import { incrementSignInClicks } from 'lib/local-storage' import Link from 'next/link' +import { useRouter } from 'next/router' import { useRef, useState } from 'react' import { Button, Form, Input } from 'ui' import { object, string } from 'yup' -import * as Sentry from '@sentry/nextjs' const signInSchema = object({ email: string().email('Must be a valid email').required('Email is required'), @@ -17,6 +20,7 @@ const signInSchema = object({ const SignInForm = () => { const { ui } = useStore() + const router = useRouter() const pushNext = usePushNext() const queryClient = useQueryClient() @@ -47,15 +51,36 @@ const SignInForm = () => { Sentry.captureMessage('Sign in without previous sign out detected') } - ui.setNotification({ - id: toastId, - category: 'success', - message: `Signed in successfully!`, - }) + try { + const data = await getMfaAuthenticatorAssuranceLevel() + if (data) { + if (data.currentLevel !== data.nextLevel) { + ui.setNotification({ + id: toastId, + category: 'success', + message: `You need to provide your second factor authentication.`, + }) + router.replace('/sign-in-mfa') + return + } + } - await queryClient.resetQueries() + ui.setNotification({ + id: toastId, + category: 'success', + message: `Signed in successfully!`, + }) - await pushNext() + await queryClient.resetQueries() + + await pushNext() + } catch (error) { + ui.setNotification({ + id: toastId, + category: 'error', + message: (error as AuthError).message, + }) + } } else { setCaptchaToken(null) captchaRef.current?.resetCaptcha() From 6fa7acd622769d7bfcf1657fb886fddaa9066cbd Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Fri, 25 Aug 2023 16:33:43 +0200 Subject: [PATCH 09/68] Change the redirects for Github and SSO to send the user back to / instead of /projects as before. It will be picked from there by the SignInLayout and WithAuth helper. --- studio/components/interfaces/SignIn/SignInSSOForm.tsx | 2 +- studio/components/interfaces/SignIn/SignInWithGitHub.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/studio/components/interfaces/SignIn/SignInSSOForm.tsx b/studio/components/interfaces/SignIn/SignInSSOForm.tsx index 3d1585c0e57..5da7eaefa02 100644 --- a/studio/components/interfaces/SignIn/SignInSSOForm.tsx +++ b/studio/components/interfaces/SignIn/SignInSSOForm.tsx @@ -38,7 +38,7 @@ const SignInSSOForm = () => { process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' ? location.origin : process.env.NEXT_PUBLIC_SITE_URL - }${BASE_PATH}${getReturnToPath()}`, + }${BASE_PATH}${getReturnToPath('/')}`, }, }) diff --git a/studio/components/interfaces/SignIn/SignInWithGitHub.tsx b/studio/components/interfaces/SignIn/SignInWithGitHub.tsx index a34d53a49b1..38edc76b4df 100644 --- a/studio/components/interfaces/SignIn/SignInWithGitHub.tsx +++ b/studio/components/interfaces/SignIn/SignInWithGitHub.tsx @@ -1,9 +1,9 @@ -import { useState } from 'react' +import * as Sentry from '@sentry/nextjs' import { BASE_PATH } from 'lib/constants' import { auth, getReturnToPath } from 'lib/gotrue' import { incrementSignInClicks } from 'lib/local-storage' +import { useState } from 'react' import { Button, IconGitHub } from 'ui' -import * as Sentry from '@sentry/nextjs' const SignInWithGitHub = () => { const [loading, setLoading] = useState(false) @@ -24,7 +24,7 @@ const SignInWithGitHub = () => { process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' ? location.origin : process.env.NEXT_PUBLIC_SITE_URL - }${BASE_PATH}${getReturnToPath()}`, + }${BASE_PATH}${getReturnToPath('/')}`, }, }) if (error) throw error From 959b38479f0ef451c0be388d7db8a6ad68520d70 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Fri, 25 Aug 2023 16:34:18 +0200 Subject: [PATCH 10/68] Redirect the user to /sign-in page if he doesn't have the appropriate AA level. --- studio/hooks/misc/withAuth.tsx | 38 ++++++++++++++++------------------ 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/studio/hooks/misc/withAuth.tsx b/studio/hooks/misc/withAuth.tsx index 21812c5c3f1..4ba0a37276b 100644 --- a/studio/hooks/misc/withAuth.tsx +++ b/studio/hooks/misc/withAuth.tsx @@ -4,11 +4,12 @@ import { ComponentType, useEffect } from 'react' import { useParams } from 'common/hooks' import { usePermissionsQuery } from 'data/permissions/permissions-query' +import { useAuthenticatorAssuranceLevelQuery } from 'data/profile/mfa-authenticator-assurance-level-query' import { useSelectedProject, useStore } from 'hooks' import { useAuth } from 'lib/auth' import { IS_PLATFORM } from 'lib/constants' -import { STORAGE_KEY, getReturnToPath } from 'lib/gotrue' -import { NextPageWithLayout, isNextPageWithLayout } from 'types' +import { getReturnToPath, STORAGE_KEY } from 'lib/gotrue' +import { isNextPageWithLayout, NextPageWithLayout } from 'types' import Error500 from '../../pages/500' const PLATFORM_ONLY_PAGES = [ @@ -19,26 +20,19 @@ const PLATFORM_ONLY_PAGES = [ 'auth/url-configuration', ] -export function withAuth( - WrappedComponent: ComponentType | NextPageWithLayout, - options?: { - redirectTo: string - /* run the redirect if the user is logged in */ - redirectIfFound?: boolean - } -) { +export function withAuth(WrappedComponent: ComponentType | NextPageWithLayout) { const WithAuthHOC: ComponentType = (props: any) => { const router = useRouter() const { basePath } = router const { ref } = useParams() const rootStore = useStore() const { isLoading, session } = useAuth() + const { isLoading: isAALLoading, data: aalData } = useAuthenticatorAssuranceLevelQuery() const { ui } = rootStore const page = router.pathname.split('/').slice(3).join('/') - const redirectTo = options?.redirectTo ?? defaultRedirectTo(ref) - const redirectIfFound = options?.redirectIfFound + const redirectTo = defaultRedirectTo(ref) usePermissionsQuery({ onError(error: any) { @@ -51,13 +45,20 @@ export function withAuth( }) const isLoggedIn = Boolean(session) + const isCorrectLevel = aalData?.currentLevel === aalData?.nextLevel const isAccessingBlockedPage = !IS_PLATFORM && PLATFORM_ONLY_PAGES.some((platformOnlyPage) => page.startsWith(platformOnlyPage)) const isRedirecting = isAccessingBlockedPage || - checkRedirectTo(isLoading, router.pathname, isLoggedIn, redirectTo, redirectIfFound) + checkRedirectTo( + isLoading || isAALLoading, + router.pathname, + isLoggedIn, + isCorrectLevel, + redirectTo + ) useEffect(() => { // This should run after setting store data @@ -114,17 +115,14 @@ function checkRedirectTo( loading: boolean, pathname: string, isLoggedIn: boolean, - redirectTo: string, - redirectIfFound?: boolean + isCorrectLevel: boolean, + redirectTo: string ) { if (loading) return false if (pathname === redirectTo) return false - // If redirectTo is set, redirect if the user is not logged in. - if (redirectTo && !redirectIfFound && !isLoggedIn) return true - - // If redirectIfFound is also set, redirect if the user was found - if (redirectIfFound && isLoggedIn) return true + // If redirectTo is set, redirect if the user is not logged in or logged in with MFA. + if (redirectTo && (!isLoggedIn || !isCorrectLevel)) return true return false } From f2f6b9681c4587f0c9b6ef03ff0944578e8bb8f4 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Fri, 25 Aug 2023 16:35:18 +0200 Subject: [PATCH 11/68] Remove quick redirect from the SignInLayout since now it doesn't mean the user is ready to work, he may have to sign in into additional factor. --- .../layouts/SignInLayout/SignInLayout.tsx | 59 ++++++++++--------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/studio/components/layouts/SignInLayout/SignInLayout.tsx b/studio/components/layouts/SignInLayout/SignInLayout.tsx index 387c0f15167..7a25b11d9d0 100644 --- a/studio/components/layouts/SignInLayout/SignInLayout.tsx +++ b/studio/components/layouts/SignInLayout/SignInLayout.tsx @@ -1,13 +1,13 @@ import { useQueryClient } from '@tanstack/react-query' import { useTheme } from 'common' -import { useFlag, useStore } from 'hooks' +import { useFlag } from 'hooks' import { usePushNext } from 'hooks/misc/useAutoAuthRedirect' -import { BASE_PATH, IS_PLATFORM } from 'lib/constants' -import { auth, getReturnToPath, STORAGE_KEY } from 'lib/gotrue' +import { BASE_PATH } from 'lib/constants' +import { auth } from 'lib/gotrue' import { observer } from 'mobx-react-lite' -import Head from 'next/head' import Image from 'next/image' import Link from 'next/link' +import { useRouter } from 'next/router' import { PropsWithChildren, useEffect, useState } from 'react' import { tweets } from 'shared-data' import { Button, IconFileText } from 'ui' @@ -26,6 +26,7 @@ const SignInLayout = ({ logoLinkToMarketingSite = false, children, }: PropsWithChildren) => { + const router = useRouter() const pushNext = usePushNext() const queryClient = useQueryClient() const { isDarkMode } = useTheme() @@ -42,24 +43,34 @@ const SignInLayout = ({ return } - ;(async () => { - const { error } = await auth.initialize() - + auth.initialize().then(({ error }) => { if (error) { // if there was a problem signing in via the url, don't redirect return } - const { - data: { session }, - } = await auth.getSession() + auth.getSession().then(async ({ data: { session } }) => { + try { + const { data, error } = await auth.mfa.getAuthenticatorAssuranceLevel() + if (error) { + // if there was a problem signing in via the url, don't redirect + return + } - if (session) { - await queryClient.resetQueries() + if (data) { + if (data.currentLevel !== data.nextLevel) { + router.replace('/sign-in-mfa') + return + } + } - await pushNext() - } - })() + if (session) { + await queryClient.resetQueries() + await pushNext() + } + } catch {} + }) + }) }, []) const [quote, setQuote] = useState<{ @@ -77,20 +88,12 @@ const SignInLayout = ({ return ( <> - {IS_PLATFORM && ( - -