diff --git a/studio/components/interfaces/App/CommandMenuWrapper.tsx b/studio/components/interfaces/App/CommandMenuWrapper.tsx index 748c2f0a0cc..c4498e11db1 100644 --- a/studio/components/interfaces/App/CommandMenuWrapper.tsx +++ b/studio/components/interfaces/App/CommandMenuWrapper.tsx @@ -8,9 +8,9 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { uuidv4 } from 'lib/helpers' import { checkPermissions, useFlag, useStore } from 'hooks' +import useProfile from 'hooks/misc/useProfile' import { createSqlSnippetSkeleton } from '../SQLEditor/SQLEditor.utils' import { useSqlEditorStateSnapshot } from 'state/sql-editor' -import { useProfileQuery } from 'data/profile/profile-query' import { SqlSnippet } from 'data/content/sql-snippets-query' import { useProjectApiQuery } from 'data/config/project-api-query' import { codeBlock } from 'common-tags' @@ -25,7 +25,7 @@ const CommandMenuWrapper = observer(({ children }: PropsWithChildren<{}>) => { const allowCMDKDataOptIn = useFlag('dashboardCmdkDataOptIn') const isOptedInToAI = opt_in_tags?.includes('AI_SQL_GENERATOR_OPT_IN') ?? false - const { data: profile } = useProfileQuery() + const { data: profile } = useProfile() const { data: settings } = useProjectApiQuery({ projectRef: ref }) const canCreateSQLSnippet = checkPermissions(PermissionAction.CREATE, 'user_content', { resource: { type: 'sql', owner_id: profile?.id }, diff --git a/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx b/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx index 3dca2403e09..5e40307f65b 100644 --- a/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx +++ b/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx @@ -6,6 +6,7 @@ import { Badge, Button, Loading, Listbox, IconUser, Modal, IconAlertCircle, Icon import { Member } from 'types' import { useStore } from 'hooks' +import useProfile from 'hooks/misc/useProfile' import { useParams } from 'common/hooks' import ShimmeringLoader from 'components/ui/ShimmeringLoader' @@ -14,7 +15,6 @@ import MemberActions from './MemberActions' import RolesHelperModal from './RolesHelperModal/RolesHelperModal' import { getRolesManagementPermissions } from './TeamSettings.utils' import { isInviteExpired, getUserDisplayName } from '../Organization.utils' -import { useProfileQuery } from 'data/profile/profile-query' import { useOrganizationRolesQuery } from 'data/organizations/organization-roles-query' import { useOrganizationDetailQuery } from 'data/organizations/organization-detail-query' import { useOrganizationMemberUpdateMutation } from 'data/organizations/organization-member-update-mutation' @@ -32,7 +32,7 @@ const MembersView = ({ searchString }: MembersViewProps) => { const { ui } = useStore() const { slug } = useParams() - const { data: profile } = useProfileQuery() + const { data: profile } = useProfile() const { data: detailData, isLoading: isLoadingOrgDetails } = useOrganizationDetailQuery({ slug }) const { data: rolesData, isLoading: isLoadingRoles } = useOrganizationRolesQuery({ slug }) const { mutate: updateOrganizationMember, isLoading } = useOrganizationMemberUpdateMutation({ diff --git a/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx b/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx index bb5f155102d..04cb1a953a2 100644 --- a/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx +++ b/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx @@ -4,10 +4,10 @@ import { Button, Input, IconSearch } from 'ui' import * as Tooltip from '@radix-ui/react-tooltip' import { useStore } from 'hooks' +import useProfile from 'hooks/misc/useProfile' import { useParams } from 'common/hooks' import { post } from 'lib/common/fetch' import { API_URL } from 'lib/constants' -import { useProfileQuery } from 'data/profile/profile-query' import InviteMemberButton from './InviteMemberButton' import MembersView from './MembersView' import { getRolesManagementPermissions, hasMultipleOwners } from './TeamSettings.utils' @@ -19,7 +19,7 @@ const TeamSettings = () => { const { ui } = useStore() const { slug } = useParams() - const { data: profile } = useProfileQuery() + const { data: profile } = useProfile() const isOwner = ui.selectedOrganization?.is_owner const { data: detailData } = useOrganizationDetailQuery({ slug }) diff --git a/studio/components/interfaces/Reports/Reports.tsx b/studio/components/interfaces/Reports/Reports.tsx index 4b41c46826b..f4215efde7f 100644 --- a/studio/components/interfaces/Reports/Reports.tsx +++ b/studio/components/interfaces/Reports/Reports.tsx @@ -17,11 +17,11 @@ import { import { PermissionAction } from '@supabase/shared-types/out/constants' import { checkPermissions } from 'hooks' +import useProfile from 'hooks/misc/useProfile' import { useParams } from 'common/hooks' import { uuidv4 } from 'lib/helpers' import { METRIC_CATEGORIES, METRICS, TIME_PERIODS_REPORTS } from 'lib/constants' import { useProjectContentStore } from 'stores/projectContentStore' -import { useProfileQuery } from 'data/profile/profile-query' import Loading from 'components/ui/Loading' import DateRangePicker from 'components/to-be-cleaned/DateRangePicker' import NoPermission from 'components/ui/NoPermission' @@ -33,7 +33,7 @@ const DEFAULT_CHART_ROW_COUNT = 4 const Reports = () => { const { id, ref } = useParams() - const { data: profile } = useProfileQuery() + const { data: profile } = useProfile() const [report, setReport] = useState() diff --git a/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx b/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx index 86f850fbce4..d1e950982a9 100644 --- a/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx +++ b/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx @@ -4,7 +4,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import Telemetry from 'lib/telemetry' import { checkPermissions, useStore } from 'hooks' -import { useProfileQuery } from 'data/profile/profile-query' +import useProfile from 'hooks/misc/useProfile' import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.constants' import SQLCard from './SQLCard' import { createSqlSnippetSkeleton } from '../SQLEditor.utils' @@ -18,7 +18,7 @@ const SQLTemplates = observer(() => { const { ui } = useStore() const { ref } = useParams() const router = useRouter() - const { data: profile } = useProfileQuery() + const { data: profile } = useProfile() const [sql, quickStart] = partition(SQL_TEMPLATES, { type: 'template' }) const telemetryProps = useTelemetryProps() diff --git a/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx b/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx index 7258a5e9a2a..1a3b564ad34 100644 --- a/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx +++ b/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx @@ -13,7 +13,7 @@ import DatePickers from './Logs.DatePickers' import Link from 'next/link' import React from 'react' import { checkPermissions } from 'hooks' -import { useProfileQuery } from 'data/profile/profile-query' +import useProfile from 'hooks/misc/useProfile' import { IS_PLATFORM } from 'lib/constants' export interface LogsQueryPanelProps { @@ -45,7 +45,7 @@ const LogsQueryPanel = ({ onDateChange, warnings, }: LogsQueryPanelProps) => { - const { data: profile } = useProfileQuery() + const { data: profile } = useProfile() const canCreateLogQuery = checkPermissions(PermissionAction.CREATE, 'user_content', { resource: { type: 'log_sql', owner_id: profile?.id }, subject: { id: profile?.id }, diff --git a/studio/components/interfaces/Support/SupportForm.tsx b/studio/components/interfaces/Support/SupportForm.tsx index f927cd86096..2247e68f821 100644 --- a/studio/components/interfaces/Support/SupportForm.tsx +++ b/studio/components/interfaces/Support/SupportForm.tsx @@ -19,10 +19,10 @@ import { CLIENT_LIBRARIES } from 'common/constants' import { Project } from 'types' import { useStore, useFlag } from 'hooks' +import useProfile from 'hooks/misc/useProfile' import { post, get } from 'lib/common/fetch' import { detectBrowser } from 'lib/helpers' import { API_URL, PRICING_TIER_PRODUCT_IDS } from 'lib/constants' -import { useProfileQuery } from 'data/profile/profile-query' import Divider from 'components/ui/Divider' import Connecting from 'components/ui/Loading' @@ -76,7 +76,7 @@ const SupportForm: FC = ({ setSentCategory }) => { } }, [uploadedFiles]) - const { data: profile } = useProfileQuery() + const { data: profile } = useProfile() const respondToEmail = profile?.primary_email ?? 'your email' if (!isInitialized) { diff --git a/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx b/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx index 75631be271e..c85e72837b6 100644 --- a/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx +++ b/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx @@ -15,8 +15,8 @@ import { import { PermissionAction } from '@supabase/shared-types/out/constants' import { checkPermissions, useFlag, useStore } from 'hooks' +import useProfile from 'hooks/misc/useProfile' import { IS_PLATFORM } from 'lib/constants' -import { useProfileQuery } from 'data/profile/profile-query' import { uuidv4 } from 'lib/helpers' import ProductMenuItem from 'components/ui/ProductMenu/ProductMenuItem' @@ -33,7 +33,7 @@ const SideBarContent = observer(() => { const { ui } = useStore() const { ref, id } = useParams() const router = useRouter() - const { data: profile } = useProfileQuery() + const { data: profile } = useProfile() const [filterString, setFilterString] = useState('') const { setPages, setIsOpen } = useCommandMenu() const showCmdkHelper = useFlag('dashboardCmdk') diff --git a/studio/components/ui/PageTelemetry.tsx b/studio/components/ui/PageTelemetry.tsx index f6de24e69b6..6d9d469ef9a 100644 --- a/studio/components/ui/PageTelemetry.tsx +++ b/studio/components/ui/PageTelemetry.tsx @@ -5,24 +5,6 @@ import { observer } from 'mobx-react-lite' import { useRouter } from 'next/router' import { FC, useEffect } from 'react' -function sanitizePageViewRoute(_route?: string) { - // remove all fragments - const noFragments = _route?.split('#')[0] - // remove sensitive params - const paramsSplits = noFragments?.split('?') - const hasParams = paramsSplits && paramsSplits?.length > 1 - - if (hasParams) { - const urlParams = new URLSearchParams(paramsSplits[1]) - const sensitiveKeys = [...urlParams.keys()].filter((x) => x.includes('token')) - const sensitiveParams = ['code', ...sensitiveKeys] - sensitiveParams.forEach((name) => urlParams.delete(name)) - return `${paramsSplits[0]}?${urlParams?.toString()}` - } - - return noFragments -} - const PageTelemetry: FC = ({ children }) => { const router = useRouter() const telemetryProps = useTelemetryProps() @@ -53,11 +35,8 @@ const PageTelemetry: FC = ({ children }) => { * * @param route: the browser url * */ - const handlePageTelemetry = async (_route?: string) => { + const handlePageTelemetry = async (route: string) => { if (IS_PLATFORM) { - // filter out sensitive query params - const route = sanitizePageViewRoute(_route) - /** * Get referrer from browser */ diff --git a/studio/data/profile/profile-create-mutation.ts b/studio/data/profile/profile-create-mutation.ts new file mode 100644 index 00000000000..01f1ffff75c --- /dev/null +++ b/studio/data/profile/profile-create-mutation.ts @@ -0,0 +1,34 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { profileKeys } from './keys' +import { Profile } from './types' + +export type ProfileResponse = Profile + +export async function createProfile() { + const response = await post(`${API_URL}/profile`, {}) + if (response.error) { + throw response.error + } + + return response as ProfileResponse +} + +type ProfileCreateData = Awaited> + +export const useProfileCreateMutation = ({ + onSuccess, + ...options +}: Omit, 'mutationFn'> = {}) => { + const queryClient = useQueryClient() + + return useMutation(() => createProfile(), { + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries(profileKeys.profile()) + + await onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/studio/data/profile/profile-query.ts b/studio/data/profile/profile-query.ts index 479cb7233aa..a6e990485a4 100644 --- a/studio/data/profile/profile-query.ts +++ b/studio/data/profile/profile-query.ts @@ -3,25 +3,16 @@ import { get } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { useCallback } from 'react' import { profileKeys } from './keys' - -export type Profile = { - id: number - auth0_id: string - primary_email: string - username: string - first_name: string - last_name: string - mobile: string | null - is_alpha_user: boolean - gotrue_id: string - free_project_limit: number -} +import { Profile } from './types' export type ProfileResponse = Profile export async function getProfile(signal?: AbortSignal) { const response = await get(`${API_URL}/profile`, { signal, + headers: { + Version: '2', + }, }) if (response.error) { throw response.error @@ -36,8 +27,8 @@ export type ProfileError = unknown export const useProfileQuery = ({ enabled = true, ...options -}: UseQueryOptions = {}) => - useQuery( +}: UseQueryOptions = {}) => { + return useQuery( profileKeys.profile(), ({ signal }) => getProfile(signal), { @@ -45,6 +36,7 @@ export const useProfileQuery = ({ ...options, } ) +} export const useProfilePrefetch = () => { const client = useQueryClient() diff --git a/studio/data/profile/profile-update-mutation.ts b/studio/data/profile/profile-update-mutation.ts index b1b9ba67252..d4633f184bb 100644 --- a/studio/data/profile/profile-update-mutation.ts +++ b/studio/data/profile/profile-update-mutation.ts @@ -1,5 +1,5 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' -import { post } from 'lib/common/fetch' +import { patch } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { profileKeys } from './keys' @@ -9,7 +9,7 @@ export type ProfileUpdateVariables = { } export async function updateProfile({ firstName, lastName }: ProfileUpdateVariables) { - const response = await post(`${API_URL}/profile/update`, { + const response = await patch(`${API_URL}/profile`, { first_name: firstName, last_name: lastName, }) diff --git a/studio/data/profile/types.ts b/studio/data/profile/types.ts new file mode 100644 index 00000000000..4af9a4d09e9 --- /dev/null +++ b/studio/data/profile/types.ts @@ -0,0 +1,12 @@ +export type Profile = { + id: number + auth0_id: string + primary_email: string + username: string + first_name: string + last_name: string + mobile: string | null + is_alpha_user: boolean + gotrue_id: string + free_project_limit: number +} diff --git a/studio/data/query-client.ts b/studio/data/query-client.ts index ee9ce6f2522..73cbb94e64b 100644 --- a/studio/data/query-client.ts +++ b/studio/data/query-client.ts @@ -4,7 +4,31 @@ import { useState } from 'react' let queryClient: QueryClient | undefined export function getQueryClient() { - const _queryClient = queryClient ?? new QueryClient() + const _queryClient = + queryClient ?? + new QueryClient({ + defaultOptions: { + queries: { + retry: (failureCount, error) => { + // Don't retry on 404s + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as any).code === 404 + ) { + return false + } + + if (failureCount < 3) { + return true + } + + return false + }, + }, + }, + }) // For SSG and SSR always create a new queryClient if (typeof window === 'undefined') return _queryClient diff --git a/studio/hooks/misc/useProfile.tsx b/studio/hooks/misc/useProfile.tsx new file mode 100644 index 00000000000..a41439114b3 --- /dev/null +++ b/studio/hooks/misc/useProfile.tsx @@ -0,0 +1,41 @@ +import { useRouter } from 'next/router' + +import { useTelemetryProps } from 'common' +import { useProfileCreateMutation } from 'data/profile/profile-create-mutation' +import { useProfileQuery } from 'data/profile/profile-query' +import Telemetry from 'lib/telemetry' + +/** + * Fetches the user's profile, creating one if it does not exist + */ +function useProfile() { + const router = useRouter() + const telemetryProps = useTelemetryProps() + + const { mutate: createProfile, isLoading: isCreating } = useProfileCreateMutation({ + onSuccess() { + Telemetry.sendEvent( + { category: 'conversion', action: 'sign_up', label: '' }, + telemetryProps, + router + ) + }, + }) + + const { isLoading, ...result } = useProfileQuery({ + onError(err) { + // if the user does not yet exist, create a profile for them + if (typeof err === 'object' && err !== null && 'code' in err && (err as any).code === 404) { + createProfile() + } + }, + }) + + return { + // Continue the loading state until the profile is created + isLoading: isLoading || isCreating, + ...result, + } +} + +export default useProfile diff --git a/studio/lib/auth.tsx b/studio/lib/auth.tsx index 973530e3afd..b42ac9c84bc 100644 --- a/studio/lib/auth.tsx +++ b/studio/lib/auth.tsx @@ -1,4 +1,5 @@ import { useQueryClient } from '@tanstack/react-query' +import { useRouter } from 'next/router' import { PropsWithChildren, useCallback, useEffect } from 'react' import { @@ -17,6 +18,7 @@ export const AuthContext = AuthContextInternal export const AuthProvider = ({ children }: PropsWithChildren<{}>) => { const { ui, app } = useStore() + const router = useRouter() const telemetryProps = useTelemetryProps() // Check for unverified GitHub users after a GitHub sign in @@ -36,6 +38,22 @@ export const AuthProvider = ({ children }: PropsWithChildren<{}>) => { handleEmailVerificationError() }, []) + useEffect(() => { + const { + data: { subscription }, + } = gotrueClient.onAuthStateChange((event) => { + if (event === 'SIGNED_IN') { + Telemetry.sendEvent( + { category: 'account', action: 'sign_in', label: '' }, + telemetryProps, + router + ) + } + }) + + return subscription.unsubscribe + }, []) + // Track telemetry for the current user useProfileQuery({ onSuccess(profile) { diff --git a/studio/pages/account/me.tsx b/studio/pages/account/me.tsx index bc98dd9c7c9..cbe90639743 100644 --- a/studio/pages/account/me.tsx +++ b/studio/pages/account/me.tsx @@ -4,9 +4,10 @@ import { Button, IconMoon, IconSun, Input, Listbox } from 'ui' import { AccountLayout } from 'components/layouts' import SchemaFormPanel from 'components/to-be-cleaned/forms/SchemaFormPanel' import Panel from 'components/ui/Panel' -import { Profile as ProfileType, useProfileQuery } from 'data/profile/profile-query' +import { Profile as ProfileType } from 'data/profile/types' import { useProfileUpdateMutation } from 'data/profile/profile-update-mutation' import { useStore } from 'hooks' +import useProfile from 'hooks/misc/useProfile' import { useSession } from 'lib/auth' import Link from 'next/link' import { NextPageWithLayout } from 'types' @@ -40,7 +41,7 @@ const ProfileCard = observer(() => { const { ui } = useStore() const { mutateAsync } = useProfileUpdateMutation() - const { data: profile } = useProfileQuery() + const { data: profile } = useProfile() // TODO: ^ handle loading state const updateUser = async (model: any) => { diff --git a/studio/pages/join/index.tsx b/studio/pages/join/index.tsx index a87cffe2e10..ce5174adbce 100644 --- a/studio/pages/join/index.tsx +++ b/studio/pages/join/index.tsx @@ -4,10 +4,10 @@ import { useRouter } from 'next/router' import { Button, IconCheckSquare, Loading } from 'ui' import { useStore } from 'hooks' +import useProfile from 'hooks/misc/useProfile' import { useSignOut } from 'lib/auth' import { API_URL } from 'lib/constants' import { get, post, delete_ } from 'lib/common/fetch' -import { useProfileQuery } from 'data/profile/profile-query' interface ITokenInfo { organization_name?: string | undefined @@ -24,7 +24,7 @@ const JoinOrganizationPage = () => { const router = useRouter() const { slug, token, name } = router.query const { ui, app } = useStore() - const { data: profile } = useProfileQuery() + const { data: profile } = useProfile() const signOut = useSignOut() const [isSubmitting, setIsSubmitting] = useState(false) diff --git a/studio/pages/project/[ref]/reports/index.tsx b/studio/pages/project/[ref]/reports/index.tsx index dfda77f3db6..a533b467837 100644 --- a/studio/pages/project/[ref]/reports/index.tsx +++ b/studio/pages/project/[ref]/reports/index.tsx @@ -5,11 +5,11 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { NextPageWithLayout } from 'types' import { checkPermissions, useFlag, useStore } from 'hooks' +import useProfile from 'hooks/misc/useProfile' import { useParams } from 'common/hooks' import { post } from 'lib/common/fetch' import { API_URL, PROJECT_STATUS } from 'lib/constants' import { useProjectContentStore } from 'stores/projectContentStore' -import { useProfileQuery } from 'data/profile/profile-query' import Loading from 'components/ui/Loading' import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' import { createReport } from 'components/to-be-cleaned/Reports/Reports.utils' @@ -21,7 +21,7 @@ export const UserReportPage: NextPageWithLayout = () => { const router = useRouter() const { ref } = useParams() - const { data: profile } = useProfileQuery() + const { data: profile } = useProfile() const { ui } = useStore() const project = ui.selectedProject