From 48836843e87f78c562d4a8a5deb3695f5b243de1 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Fri, 25 Nov 2022 15:17:21 +0000 Subject: [PATCH 01/83] move useProjectUsage to react-query --- .../Billing/Subscription/Subscription.tsx | 18 ++-- .../interfaces/Home/ProjectUsage.tsx | 1 + .../interfaces/Home/ProjectUsageSection.tsx | 13 ++- .../ProjectUsageBars/ProjectUsageBars.tsx | 13 +-- .../ProjectUsageBars/ProjectUsageMinimal.tsx | 11 ++- .../LayoutHeader/LayoutHeader.tsx | 11 +-- .../ui/OveragesBanner/OveragesBanner.tsx | 11 +-- studio/data/usage/keys.ts | 3 + studio/data/usage/project-usage-query.ts | 92 +++++++++++++++++++ studio/hooks/queries/index.ts | 1 - studio/hooks/queries/useProjectUsage.ts | 17 ---- .../pages/project/[ref]/reports/database.tsx | 11 +-- .../project/[ref]/settings/billing/usage.tsx | 4 +- 13 files changed, 142 insertions(+), 64 deletions(-) create mode 100644 studio/data/usage/keys.ts create mode 100644 studio/data/usage/project-usage-query.ts delete mode 100644 studio/hooks/queries/useProjectUsage.ts diff --git a/studio/components/interfaces/Billing/Subscription/Subscription.tsx b/studio/components/interfaces/Billing/Subscription/Subscription.tsx index 22909243baa..d427753f214 100644 --- a/studio/components/interfaces/Billing/Subscription/Subscription.tsx +++ b/studio/components/interfaces/Billing/Subscription/Subscription.tsx @@ -5,7 +5,7 @@ import { Button, Loading } from 'ui' import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { checkPermissions, useStore, useFlag, useProjectUsage } from 'hooks' +import { checkPermissions, useStore, useFlag, useParams } from 'hooks' import { STRIPE_PRODUCT_IDS } from 'lib/constants' import { formatBytes } from 'lib/helpers' @@ -14,6 +14,7 @@ import CostBreakdownRow from './CostBreakdownRow' import { StripeSubscription } from './Subscription.types' import NoPermission from 'components/ui/NoPermission' import { USAGE_BASED_PRODUCTS } from 'components/interfaces/Billing/Billing.constants' +import { ProjectUsageResponse, useProjectUsageQuery } from 'data/usage/project-usage-query' interface Props { project: any @@ -34,8 +35,7 @@ const Subscription: FC = ({ }) => { const { ui } = useStore() const router = useRouter() - - const { ref } = router.query + const { ref: projectRef } = useParams() const enablePermissions = useFlag('enablePermissions') const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') @@ -44,7 +44,7 @@ const Subscription: FC = ({ ? checkPermissions(PermissionAction.BILLING_WRITE, 'stripe.subscriptions') : ui.selectedOrganization?.is_owner - const { usage, isLoading: loadingUsage } = useProjectUsage(ref as string) + const { data: usage, isLoading: loadingUsage } = useProjectUsageQuery({ projectRef }) const isPayg = subscription?.tier.prod_id === STRIPE_PRODUCT_IDS.PAYG const isEnterprise = subscription.tier.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.ENTERPRISE @@ -62,7 +62,7 @@ const Subscription: FC = ({ ? 0 : Object.keys(usage) .map((productKey) => { - return usage[productKey].cost + return usage[productKey as keyof ProjectUsageResponse].cost }) .reduce((prev, current) => prev + current, 0) @@ -185,9 +185,11 @@ const Subscription: FC = ({ {isPayg && USAGE_BASED_PRODUCTS.map((product) => { return product.features.map((feature) => { - const amount = usage?.[feature.key]?.usage ?? 0 - const limit = usage?.[feature.key]?.limit ?? 0 - const cost = (usage?.[feature.key]?.cost ?? 0).toFixed(2) + const amount = usage?.[feature.key as keyof ProjectUsageResponse]?.usage ?? 0 + const limit = usage?.[feature.key as keyof ProjectUsageResponse]?.limit ?? 0 + const cost = ( + usage?.[feature.key as keyof ProjectUsageResponse]?.cost ?? 0 + ).toFixed(2) return ( = ({}) => { `${API_URL}/projects/${ref}/log-stats?interval=${interval}`, get ) + console.log('data:', data) const selectedInterval = CHART_INTERVALS.find((i) => i.key === interval) || CHART_INTERVALS[1] const startDate = dayjs() diff --git a/studio/components/interfaces/Home/ProjectUsageSection.tsx b/studio/components/interfaces/Home/ProjectUsageSection.tsx index 2ac8f326c6c..3b83e7d1c96 100644 --- a/studio/components/interfaces/Home/ProjectUsageSection.tsx +++ b/studio/components/interfaces/Home/ProjectUsageSection.tsx @@ -1,16 +1,15 @@ import { FC } from 'react' -import { useRouter } from 'next/router' import { observer } from 'mobx-react-lite' import { IconLoader, IconAlertCircle } from 'ui' -import { useProjectUsage } from 'hooks' +import { useParams } from 'hooks' import { ProjectUsage, NewProjectPanel } from 'components/interfaces/Home' import InformationBox from 'components/ui/InformationBox' +import { ProjectUsageResponse, useProjectUsageQuery } from 'data/usage/project-usage-query' const ProjectUsageSection: FC = observer(({}) => { - const router = useRouter() - const { ref } = router.query - const { usage, error: usageError, isLoading } = useProjectUsage(ref as string) + const { ref: projectRef } = useParams() + const { data: usage, error: usageError, isLoading } = useProjectUsageQuery({ projectRef }) if (usageError) { return ( @@ -25,8 +24,8 @@ const ProjectUsageSection: FC = observer(({}) => { const hasProjectData = usage ? Object.keys(usage) - .map((key) => usage[key].usage) - .some((usage) => usage > 0) + .map((key) => usage[key as keyof ProjectUsageResponse].usage) + .some((usage) => (usage ?? 0) > 0) : false return ( diff --git a/studio/components/interfaces/Settings/ProjectUsageBars/ProjectUsageBars.tsx b/studio/components/interfaces/Settings/ProjectUsageBars/ProjectUsageBars.tsx index 0e703a15e96..0d96e424063 100644 --- a/studio/components/interfaces/Settings/ProjectUsageBars/ProjectUsageBars.tsx +++ b/studio/components/interfaces/Settings/ProjectUsageBars/ProjectUsageBars.tsx @@ -1,13 +1,14 @@ import { FC, useEffect } from 'react' import { Badge, IconAlertCircle, Loading } from 'ui' -import { useStore, useProjectUsage } from 'hooks' +import { useStore } from 'hooks' import { formatBytes } from 'lib/helpers' import { PRICING_TIER_PRODUCT_IDS, USAGE_APPROACHING_THRESHOLD } from 'lib/constants' import SparkBar from 'components/ui/SparkBar' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import InformationBox from 'components/ui/InformationBox' import { USAGE_BASED_PRODUCTS } from 'components/interfaces/Billing/Billing.constants' +import { ProjectUsageResponse, useProjectUsageQuery } from 'data/usage/project-usage-query' interface Props { projectRef?: string @@ -15,7 +16,7 @@ interface Props { const ProjectUsage: FC = ({ projectRef }) => { const { ui } = useStore() - const { usage, error, isLoading } = useProjectUsage(projectRef) + const { data: usage, error, isLoading } = useProjectUsageQuery({ projectRef }) const projectHasNoLimits = ui.selectedProject?.subscription_tier === PRICING_TIER_PRODUCT_IDS.PAYG || @@ -28,7 +29,7 @@ const ProjectUsage: FC = ({ projectRef }) => { if (error) { ui.setNotification({ category: 'error', - message: `Failed to get project's usage data: ${error?.message ?? 'unknown'}`, + message: `Failed to get project's usage data: ${(error as any)?.message ?? 'unknown'}`, }) } }, [error]) @@ -53,8 +54,8 @@ const ProjectUsage: FC = ({ projectRef }) => { showUsageExceedMessage && product.features .map((feature) => { - const featureUsage = usage[feature.key] - return featureUsage.usage / featureUsage.limit > 1 + const featureUsage = usage[feature.key as keyof ProjectUsageResponse] + return (featureUsage.usage ?? 0) / featureUsage.limit > 1 }) .some((x) => x === true) return ( @@ -99,7 +100,7 @@ const ProjectUsage: FC = ({ projectRef }) => { ) : ( {product.features.map((feature) => { - const featureUsage = usage[feature.key] + const featureUsage = usage[feature.key as keyof ProjectUsageResponse] const usageValue = featureUsage.usage || 0 const usageRatio = usageValue / featureUsage.limit const isApproaching = usageRatio >= USAGE_APPROACHING_THRESHOLD diff --git a/studio/components/interfaces/Settings/ProjectUsageBars/ProjectUsageMinimal.tsx b/studio/components/interfaces/Settings/ProjectUsageBars/ProjectUsageMinimal.tsx index b541753edb0..c05a464cf8c 100644 --- a/studio/components/interfaces/Settings/ProjectUsageBars/ProjectUsageMinimal.tsx +++ b/studio/components/interfaces/Settings/ProjectUsageBars/ProjectUsageMinimal.tsx @@ -1,11 +1,12 @@ import { FC } from 'react' import { Loading } from 'ui' -import { useProjectSubscription, useProjectUsage } from 'hooks' +import { useProjectSubscription } from 'hooks' import { formatBytes } from 'lib/helpers' import { PRICING_TIER_PRODUCT_IDS, USAGE_APPROACHING_THRESHOLD } from 'lib/constants' import SparkBar from 'components/ui/SparkBar' import { USAGE_BASED_PRODUCTS } from 'components/interfaces/Billing/Billing.constants' +import { ProjectUsageResponse, useProjectUsageQuery } from 'data/usage/project-usage-query' interface ProjectUsageMinimalProps { projectRef?: string @@ -15,7 +16,7 @@ interface ProjectUsageMinimalProps { // [Joshen] This is currently not being used anywhere as of 011122 const ProjectUsageMinimal: FC = ({ projectRef, filter }) => { - const { usage, error: usageError, isLoading } = useProjectUsage(projectRef) + const { data: usage, error: usageError, isLoading } = useProjectUsageQuery({ projectRef }) const { subscription, error: subscriptionError } = useProjectSubscription(projectRef) if ( @@ -37,8 +38,8 @@ const ProjectUsageMinimal: FC = ({ projectRef, filter {usage && (
{product.features.map((feature) => { - const featureUsage = usage[feature.key] - const usageRatio = featureUsage.usage / featureUsage.limit + const featureUsage = usage[feature.key as keyof ProjectUsageResponse] + const usageRatio = (featureUsage.usage ?? 0) / featureUsage.limit const isApproaching = usageRatio >= USAGE_APPROACHING_THRESHOLD const isExceeded = usageRatio >= 1 @@ -50,7 +51,7 @@ const ProjectUsageMinimal: FC = ({ projectRef, filter barClass={`${ isExceeded ? 'bg-red-900' : isApproaching ? 'bg-yellow-900' : 'bg-brand-900' }`} - value={featureUsage.usage} + value={featureUsage.usage ?? 0} max={featureUsage.limit} labelBottom={formatBytes(featureUsage.usage)} labelTop={formatBytes(featureUsage.limit)} diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx index a218a446c35..0b6d31fd8ad 100644 --- a/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx +++ b/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx @@ -1,9 +1,8 @@ import Link from 'next/link' import { observer } from 'mobx-react-lite' -import { useRouter } from 'next/router' import { IS_PLATFORM, PRICING_TIER_PRODUCT_IDS } from 'lib/constants' -import { useStore, useProjectUsage } from 'hooks' +import { useParams, useStore } from 'hooks' import BreadcrumbsView from './BreadcrumbsView' import OrgDropdown from './OrgDropdown' import ProjectDropdown from './ProjectDropdown' @@ -12,15 +11,15 @@ import HelpPopover from './HelpPopover' import NotificationsPopover from './NotificationsPopover' import { Badge } from 'ui' import { getResourcesExceededLimits } from 'components/ui/OveragesBanner/OveragesBanner.utils' +import { useProjectUsageQuery } from 'data/usage/project-usage-query' const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder = true }: any) => { const { ui } = useStore() const { selectedOrganization, selectedProject } = ui - const router = useRouter() - const { ref } = router.query + const { ref: projectRef } = useParams() - const { usage } = useProjectUsage(ref as string) + const { data: usage } = useProjectUsageQuery({ projectRef }) const resourcesExceededLimits = getResourcesExceededLimits(usage) const projectHasNoLimits = ui.selectedProject?.subscription_tier === PRICING_TIER_PRODUCT_IDS.PAYG || @@ -65,7 +64,7 @@ const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder = {showOverUsageBadge && (
- + Project has exceeded usage limits diff --git a/studio/components/ui/OveragesBanner/OveragesBanner.tsx b/studio/components/ui/OveragesBanner/OveragesBanner.tsx index d12517d9ad8..64b43d82749 100644 --- a/studio/components/ui/OveragesBanner/OveragesBanner.tsx +++ b/studio/components/ui/OveragesBanner/OveragesBanner.tsx @@ -2,9 +2,10 @@ import Link from 'next/link' import { FC } from 'react' import { Alert, Button } from 'ui' -import { useStore, useProjectUsage } from 'hooks' +import { useParams } from 'hooks' import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants' import { getResourcesApproachingLimits, getResourcesExceededLimits } from './OveragesBanner.utils' +import { useProjectUsageQuery } from 'data/usage/project-usage-query' interface Props { tier: string @@ -14,10 +15,8 @@ interface Props { // Banner will not be shown for PAYG or Enterprise projects const OveragesBanner: FC = ({ tier, minimal }) => { - const { ui } = useStore() - const ref = ui.selectedProject?.ref - - const { usage, error, isLoading } = useProjectUsage(ref as string) + const { ref: projectRef } = useParams() + const { data: usage, error, isLoading } = useProjectUsageQuery({ projectRef }) const resourcesApproachingLimits = getResourcesApproachingLimits(usage) const isApproachingUsageLimits = resourcesApproachingLimits.length > 0 @@ -85,7 +84,7 @@ const OveragesBanner: FC = ({ tier, minimal }) => { actions={ minimal ? (
- + diff --git a/studio/data/usage/keys.ts b/studio/data/usage/keys.ts new file mode 100644 index 00000000000..b37e6e2454f --- /dev/null +++ b/studio/data/usage/keys.ts @@ -0,0 +1,3 @@ +export const usageKeys = { + usage: (projectRef: string | undefined) => ['projects', projectRef, 'usage'] as const, +} diff --git a/studio/data/usage/project-usage-query.ts b/studio/data/usage/project-usage-query.ts new file mode 100644 index 00000000000..b487e78039e --- /dev/null +++ b/studio/data/usage/project-usage-query.ts @@ -0,0 +1,92 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { usageKeys } from './keys' + +export type ProjectUsageVariables = { + projectRef?: string +} + +export interface DbSize { + usage: number | null + limit: number + cost: number + current: number +} + +export interface DbEgress { + usage: number + limit: number + cost: number +} + +export interface StorageSize { + usage: number | null + limit: number + cost: number + current: number +} + +export interface StorageEgress { + usage: number + limit: number + cost: number +} + +export interface MonthlyActiveUsers { + usage: number + limit: number + cost: number +} + +export type ProjectUsageResponse = { + db_size: DbSize + db_egress: DbEgress + storage_size: StorageSize + storage_egress: StorageEgress + monthly_active_users: MonthlyActiveUsers +} + +export async function getProjectUsage({ projectRef }: ProjectUsageVariables, signal?: AbortSignal) { + if (!projectRef) { + throw new Error('projectRef is required') + } + + const response = await get(`${API_URL}/projects/${projectRef}/usage`, { + signal, + }) + if (response.error) { + throw response.error + } + + return response as ProjectUsageResponse +} + +export type ProjectUsageData = Awaited> +export type ProjectUsageError = unknown + +export const useProjectUsageQuery = ( + { projectRef }: ProjectUsageVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + usageKeys.usage(projectRef), + ({ signal }) => getProjectUsage({ projectRef }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + ...options, + } + ) + +export const useProjectUsagePrefetch = ({ projectRef }: ProjectUsageVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (projectRef) { + client.prefetchQuery(usageKeys.usage(projectRef), ({ signal }) => + getProjectUsage({ projectRef }, signal) + ) + } + }, [projectRef]) +} diff --git a/studio/hooks/queries/index.ts b/studio/hooks/queries/index.ts index b93e9618493..f2ec3fe6594 100644 --- a/studio/hooks/queries/index.ts +++ b/studio/hooks/queries/index.ts @@ -8,5 +8,4 @@ export * from './useProjectAuthConfig' export * from './useProjectSettings' export * from './useProjectSubscription' export * from './useSubscriptionStats' -export * from './useProjectUsage' export * from './useProjectPostgrestConfig' diff --git a/studio/hooks/queries/useProjectUsage.ts b/studio/hooks/queries/useProjectUsage.ts deleted file mode 100644 index aa1eb266c55..00000000000 --- a/studio/hooks/queries/useProjectUsage.ts +++ /dev/null @@ -1,17 +0,0 @@ -import useSWR from 'swr' -import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' -import { UsageStats } from 'components/interfaces/Settings/ProjectUsageBars/ProjectUsageBars.types' - -export function useProjectUsage(ref?: string) { - const url = `${API_URL}/projects/${ref}/usage` - const { data, error } = useSWR(ref ? url : null, get) - const anyError = data?.error || error - - return { - usage: anyError ? undefined : (data as UsageStats), - error: anyError, - isLoading: !anyError && !data, - isError: !!anyError, - } -} diff --git a/studio/pages/project/[ref]/reports/database.tsx b/studio/pages/project/[ref]/reports/database.tsx index 75c8f6ee23f..aafe7575d28 100644 --- a/studio/pages/project/[ref]/reports/database.tsx +++ b/studio/pages/project/[ref]/reports/database.tsx @@ -1,12 +1,11 @@ import dayjs from 'dayjs' -import { useRouter } from 'next/router' import { observer } from 'mobx-react-lite' import { FC, useEffect, useState } from 'react' import { IconArrowRight } from 'ui' -import { useStore, useProjectUsage } from 'hooks' -import { formatBytes } from 'lib/helpers' +import { useParams, useStore } from 'hooks' import { TIME_PERIODS_INFRA, USAGE_APPROACHING_THRESHOLD } from 'lib/constants' +import { formatBytes } from 'lib/helpers' import { NextPageWithLayout } from 'types' import ReportsLayout from 'components/layouts/ReportsLayout/ReportsLayout' @@ -14,6 +13,7 @@ import ChartHandler from 'components/to-be-cleaned/Charts/ChartHandler' import DateRangePicker from 'components/to-be-cleaned/DateRangePicker' import Panel from 'components/ui/Panel' import SparkBar from 'components/ui/SparkBar' +import { useProjectUsageQuery } from 'data/usage/project-usage-query' const DatabaseReport: NextPageWithLayout = () => { const { ui } = useStore() @@ -35,13 +35,12 @@ DatabaseReport.getLayout = (page) => {page} = () => { - const router = useRouter() const { meta, ui } = useStore() const [databaseSize, setDatabaseSize] = useState(0) const [dateRange, setDateRange] = useState(undefined) - const { ref } = router.query - const { usage } = useProjectUsage(ref as string) + const { ref: projectRef } = useParams() + const { data: usage } = useProjectUsageQuery({ projectRef }) const databaseSizeLimit = usage?.db_size?.limit ?? 0 const databaseEgressLimit = usage?.db_egress?.limit ?? 0 diff --git a/studio/pages/project/[ref]/settings/billing/usage.tsx b/studio/pages/project/[ref]/settings/billing/usage.tsx index a787a95f315..a2b434ca228 100644 --- a/studio/pages/project/[ref]/settings/billing/usage.tsx +++ b/studio/pages/project/[ref]/settings/billing/usage.tsx @@ -10,7 +10,7 @@ import { SettingsLayout } from 'components/layouts' import LoadingUI from 'components/ui/Loading' import DateRangePicker from 'components/to-be-cleaned/DateRangePicker' import { PAYGUsage } from 'components/interfaces/Billing' -import ProjectUsage from 'components/interfaces/Settings/ProjectUsageBars/ProjectUsageBars' +import ProjectUsageBars from 'components/interfaces/Settings/ProjectUsageBars/ProjectUsageBars' const ProjectBillingUsage: NextPageWithLayout = () => { const { ui } = useStore() @@ -97,7 +97,7 @@ const Settings: FC = ({ project }) => { {dateRange && }
) : ( - + )}
) From eed9167b7ce0bdbc4f2313c9bae44f926f55300b Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Fri, 25 Nov 2022 15:18:40 +0000 Subject: [PATCH 02/83] remove console.log --- studio/components/interfaces/Home/ProjectUsage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/studio/components/interfaces/Home/ProjectUsage.tsx b/studio/components/interfaces/Home/ProjectUsage.tsx index 058b5644c41..1b930dff772 100644 --- a/studio/components/interfaces/Home/ProjectUsage.tsx +++ b/studio/components/interfaces/Home/ProjectUsage.tsx @@ -33,7 +33,6 @@ const ProjectUsage: FC = ({}) => { `${API_URL}/projects/${ref}/log-stats?interval=${interval}`, get ) - console.log('data:', data) const selectedInterval = CHART_INTERVALS.find((i) => i.key === interval) || CHART_INTERVALS[1] const startDate = dayjs() From 4852df732d7ecd00357946bf017db96929928e06 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Fri, 25 Nov 2022 15:57:46 +0000 Subject: [PATCH 03/83] move useProfile to react-query --- studio/data/profile/keys.ts | 3 ++ studio/data/profile/profile-query.ts | 52 +++++++++++++++++++ .../data/profile/profile-update-mutation.ts | 45 ++++++++++++++++ studio/hooks/misc/withAuth.tsx | 37 +++++++------ studio/hooks/queries/index.ts | 1 - studio/hooks/queries/useProfile.ts | 35 ------------- studio/pages/account/me.tsx | 19 +++---- studio/pages/join/index.tsx | 5 +- studio/types/base.ts | 2 +- 9 files changed, 132 insertions(+), 67 deletions(-) create mode 100644 studio/data/profile/keys.ts create mode 100644 studio/data/profile/profile-query.ts create mode 100644 studio/data/profile/profile-update-mutation.ts delete mode 100644 studio/hooks/queries/useProfile.ts diff --git a/studio/data/profile/keys.ts b/studio/data/profile/keys.ts new file mode 100644 index 00000000000..49ba29ad7b7 --- /dev/null +++ b/studio/data/profile/keys.ts @@ -0,0 +1,3 @@ +export const profileKeys = { + profile: () => ['profile'] as const, +} diff --git a/studio/data/profile/profile-query.ts b/studio/data/profile/profile-query.ts new file mode 100644 index 00000000000..d48256ef0d3 --- /dev/null +++ b/studio/data/profile/profile-query.ts @@ -0,0 +1,52 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +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 +} + +export type ProfileResponse = Profile + +export async function getProfile(signal?: AbortSignal) { + const response = await get(`${API_URL}/profile`, { + signal, + }) + if (response.error) { + throw response.error + } + + return response as ProfileResponse +} + +export type ProfileData = Awaited> +export type ProfileError = unknown + +export const useProfileQuery = ({ + enabled = true, + ...options +}: UseQueryOptions = {}) => + useQuery( + profileKeys.profile(), + ({ signal }) => getProfile(signal), + options + ) + +export const useProfilePrefetch = () => { + const client = useQueryClient() + + return useCallback(() => { + client.prefetchQuery(profileKeys.profile(), ({ signal }) => getProfile(signal)) + }, []) +} diff --git a/studio/data/profile/profile-update-mutation.ts b/studio/data/profile/profile-update-mutation.ts new file mode 100644 index 00000000000..b1b9ba67252 --- /dev/null +++ b/studio/data/profile/profile-update-mutation.ts @@ -0,0 +1,45 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { profileKeys } from './keys' + +export type ProfileUpdateVariables = { + firstName: string + lastName: string +} + +export async function updateProfile({ firstName, lastName }: ProfileUpdateVariables) { + const response = await post(`${API_URL}/profile/update`, { + first_name: firstName, + last_name: lastName, + }) + if (response.error) { + throw response.error + } + + return response +} + +type ProfileUpdateData = Awaited> + +export const useProfileUpdateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => updateProfile(vars), + { + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries(profileKeys.profile()) + + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/hooks/misc/withAuth.tsx b/studio/hooks/misc/withAuth.tsx index bd0bfc2e287..b0757765064 100644 --- a/studio/hooks/misc/withAuth.tsx +++ b/studio/hooks/misc/withAuth.tsx @@ -4,9 +4,10 @@ import { NextRouter, useRouter } from 'next/router' import { getReturnToPath, STORAGE_KEY } from 'lib/gotrue' import { IS_PLATFORM } from 'lib/constants' -import { useProfile, useStore, usePermissions } from 'hooks' +import { useStore, usePermissions } from 'hooks' import Error500 from '../../pages/500' import { NextPageWithLayout } from 'types' +import { useProfileQuery } from 'data/profile/profile-query' const PLATFORM_ONLY_PAGES = ['reports', 'settings'] @@ -28,14 +29,25 @@ export function withAuth( const redirectTo = options?.redirectTo ?? defaultRedirectTo(ref) const redirectIfFound = options?.redirectIfFound - const returning = - app.projects.isInitialized && app.organizations.isInitialized ? 'minimal' : undefined - const { profile, isLoading, error } = useProfile(returning) + const { + data: profile, + isLoading, + error, + } = useProfileQuery({ + onSuccess(profile) { + ui.setProfile(profile) + + if (!app.organizations.isInitialized) app.organizations.load() + if (!app.projects.isInitialized) app.projects.load() + mutatePermissions() + }, + }) + const { permissions, isLoading: isPermissionLoading, mutate: mutatePermissions, - } = usePermissions(profile, returning) + } = usePermissions(profile) const isAccessingBlockedPage = !IS_PLATFORM && PLATFORM_ONLY_PAGES.includes(page) const isRedirecting = @@ -43,19 +55,6 @@ export function withAuth( checkRedirectTo(isLoading, router, profile, error, redirectTo, redirectIfFound) useEffect(() => { - // This should run before redirecting - if (!isLoading) { - if (!profile) { - ui.setProfile(undefined) - } else if (returning !== 'minimal') { - ui.setProfile(profile) - - if (!app.organizations.isInitialized) app.organizations.load() - if (!app.projects.isInitialized) app.projects.load() - mutatePermissions() - } - } - if (!isPermissionLoading) { ui.setPermissions(permissions) } @@ -64,7 +63,7 @@ export function withAuth( if (isRedirecting) { router.push(redirectTo) } - }, [isLoading, isPermissionLoading, isRedirecting, profile, permissions]) + }, [isLoading, isPermissionLoading, isRedirecting, permissions]) useEffect(() => { if (!isLoading && router.isReady) { diff --git a/studio/hooks/queries/index.ts b/studio/hooks/queries/index.ts index f2ec3fe6594..0722767ed0e 100644 --- a/studio/hooks/queries/index.ts +++ b/studio/hooks/queries/index.ts @@ -3,7 +3,6 @@ export * from './useOrganizationDetail' export * from './useOrganizationRoles' export * from './useFreeProjectLimitCheck' export * from './usePermissions' -export * from './useProfile' export * from './useProjectAuthConfig' export * from './useProjectSettings' export * from './useProjectSubscription' diff --git a/studio/hooks/queries/useProfile.ts b/studio/hooks/queries/useProfile.ts deleted file mode 100644 index b4f8f78d79f..00000000000 --- a/studio/hooks/queries/useProfile.ts +++ /dev/null @@ -1,35 +0,0 @@ -import useSWR, { mutate } from 'swr' -import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' -import { User } from 'types' - -export function useProfile(returning?: 'minimal') { - let url = `${API_URL}/profile` - - if (returning) { - const query = new URLSearchParams({ returning }).toString() - url = `${url}?${query}` - } - - const { data, error } = useSWR(url, get, { loadingTimeout: 10000 }) - const anyError = data?.error || error - - function mutateProfile(updatedUser: User, revalidate?: boolean) { - mutate( - url, - { - first_name: updatedUser.first_name, - last_name: updatedUser.last_name, - }, - revalidate ?? true - ) - } - - return { - profile: anyError ? undefined : data, - isLoading: !anyError && !data, - isError: !!anyError, - error: anyError, - mutateProfile, - } -} diff --git a/studio/pages/account/me.tsx b/studio/pages/account/me.tsx index 9b3cbad9551..e5cdeeeb8bd 100644 --- a/studio/pages/account/me.tsx +++ b/studio/pages/account/me.tsx @@ -1,17 +1,16 @@ import { observer } from 'mobx-react-lite' import { useEffect, useState } from 'react' -import { Button, IconArrowRight, IconMoon, IconSun, Input, Listbox } from 'ui' +import { Button, IconMoon, IconSun, Input, Listbox } from 'ui' import { Session } from '@supabase/supabase-js' import { AccountLayout } from 'components/layouts' import SchemaFormPanel from 'components/to-be-cleaned/forms/SchemaFormPanel' import Panel from 'components/ui/Panel' -import { useProfile, useStore } from 'hooks' -import { post } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' +import { useProfileUpdateMutation } from 'data/profile/profile-update-mutation' +import { useStore } from 'hooks' import { auth } from 'lib/gotrue' -import { NextPageWithLayout } from 'types' import Link from 'next/link' +import { NextPageWithLayout } from 'types' const User: NextPageWithLayout = () => { return ( @@ -39,14 +38,16 @@ export default User const ProfileCard = observer(() => { const { ui } = useStore() - const { mutateProfile } = useProfile() + const { mutate } = useProfileUpdateMutation() const user = ui.profile const updateUser = async (model: any) => { try { - const updatedUser = await post(`${API_URL}/profile/update`, model) - mutateProfile(updatedUser, false) - ui.setProfile(updatedUser) + mutate({ + firstName: model.first_name, + lastName: model.last_name, + }) + ui.setNotification({ category: 'success', message: 'Successfully saved profile' }) } catch (error) { ui.setNotification({ diff --git a/studio/pages/join/index.tsx b/studio/pages/join/index.tsx index be1c151a700..120db3cb51b 100644 --- a/studio/pages/join/index.tsx +++ b/studio/pages/join/index.tsx @@ -3,10 +3,11 @@ import { useState, useEffect } from 'react' import { useRouter } from 'next/router' import { Button, IconCheckSquare, Loading } from 'ui' -import { useProfile, useStore } from 'hooks' +import { useStore } from 'hooks' import { auth } from 'lib/gotrue' 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 @@ -23,7 +24,7 @@ const JoinOrganizationPage = () => { const router = useRouter() const { slug, token, name } = router.query const { ui, app } = useStore() - const { profile } = useProfile() + const { data: profile } = useProfileQuery() const [isSubmitting, setIsSubmitting] = useState(false) const [error, setError] = useState(false) diff --git a/studio/types/base.ts b/studio/types/base.ts index c86d136afe7..83a36b88918 100644 --- a/studio/types/base.ts +++ b/studio/types/base.ts @@ -41,7 +41,7 @@ export interface Project extends ProjectBase { export interface User { id: number - mobile: string + mobile: string | null primary_email: string username: string first_name: string From b8a2bed1d04b8b9a895795733f5c20a4b5b3afcc Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Fri, 25 Nov 2022 16:27:56 +0000 Subject: [PATCH 04/83] move usePermissions to react-query --- studio/data/permissions/keys.ts | 3 ++ studio/data/permissions/permissions-query.ts | 40 +++++++++++++++++++ studio/hooks/index.ts | 1 + .../useCheckPermissions.ts} | 35 ++-------------- studio/hooks/misc/withAuth.tsx | 32 +++++++-------- studio/hooks/queries/index.ts | 1 - 6 files changed, 62 insertions(+), 50 deletions(-) create mode 100644 studio/data/permissions/keys.ts create mode 100644 studio/data/permissions/permissions-query.ts rename studio/hooks/{queries/usePermissions.ts => misc/useCheckPermissions.ts} (56%) diff --git a/studio/data/permissions/keys.ts b/studio/data/permissions/keys.ts new file mode 100644 index 00000000000..c2598a44758 --- /dev/null +++ b/studio/data/permissions/keys.ts @@ -0,0 +1,3 @@ +export const permissionKeys = { + permissions: () => ['permissions'] as const, +} diff --git a/studio/data/permissions/permissions-query.ts b/studio/data/permissions/permissions-query.ts new file mode 100644 index 00000000000..9f5367e47ec --- /dev/null +++ b/studio/data/permissions/permissions-query.ts @@ -0,0 +1,40 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { Permission } from 'types' +import { permissionKeys } from './keys' + +export type PermissionsResponse = Permission[] + +export async function getPermissions(signal?: AbortSignal) { + const response = await get(`${API_URL}/profile/permissions`, { + signal, + }) + if (response.error) { + throw response.error + } + + return response as PermissionsResponse +} + +export type PermissionsData = Awaited> +export type PermissionsError = unknown + +export const usePermissionsQuery = ({ + enabled = true, + ...options +}: UseQueryOptions = {}) => + useQuery( + permissionKeys.permissions(), + ({ signal }) => getPermissions(signal), + options + ) + +export const usePermissionsPrefetch = () => { + const client = useQueryClient() + + return useCallback(() => { + client.prefetchQuery(permissionKeys.permissions(), ({ signal }) => getPermissions(signal)) + }, []) +} diff --git a/studio/hooks/index.ts b/studio/hooks/index.ts index 53628ed64e5..315397b39e5 100644 --- a/studio/hooks/index.ts +++ b/studio/hooks/index.ts @@ -4,6 +4,7 @@ export * from './misc/useOptimisticSqlSnippetCreate' export * from './misc/useNotifications' export * from './misc/usePlatformStatus' export * from './misc/useParams' +export * from './misc/useCheckPermissions' export * from './deprecated' export * from './queries' export * from './ui' diff --git a/studio/hooks/queries/usePermissions.ts b/studio/hooks/misc/useCheckPermissions.ts similarity index 56% rename from studio/hooks/queries/usePermissions.ts rename to studio/hooks/misc/useCheckPermissions.ts index a7091aa4dda..770e2197ce0 100644 --- a/studio/hooks/queries/usePermissions.ts +++ b/studio/hooks/misc/useCheckPermissions.ts @@ -1,33 +1,6 @@ -import jsonLogic from 'json-logic-js' import { useFlag, useStore } from 'hooks' -import { get } from 'lib/common/fetch' -import { API_URL, IS_PLATFORM } from 'lib/constants' -import useSWR from 'swr' - -export function usePermissions(profile?: any, returning?: 'minimal') { - let url = `${API_URL}/profile/permissions` - - if (returning) { - const query = new URLSearchParams({ returning }).toString() - url = `${url}?${query}` - } - - const { - data: data, - error, - mutate, - } = useSWR(profile !== undefined && IS_PLATFORM ? url : null, get, { - loadingTimeout: 10000, - }) - const anyError = data?.error || error - - return { - permissions: IS_PLATFORM ? (anyError ? undefined : data) : [], - isLoading: !anyError && !data, - isError: !!anyError, - mutate, - } -} +import jsonLogic from 'json-logic-js' +import { IS_PLATFORM } from 'lib/constants' const toRegexpString = (actionOrResource: string) => `^${actionOrResource.replace('.', '\\.').replace('%', '.*')}$` @@ -44,7 +17,7 @@ export function checkPermissions( if (!enablePermissions) return true const { ui } = useStore() - const orgid = organizationId ?? ui?.selectedOrganization?.id + const orgId = organizationId ?? ui?.selectedOrganization?.id return (ui?.permissions ?? []) .filter( @@ -56,7 +29,7 @@ export function checkPermissions( }) => permission.actions.some((act) => (action ? action.match(toRegexpString(act)) : null)) && permission.resources.some((res) => resource.match(toRegexpString(res))) && - permission.organization_id === orgid + permission.organization_id === orgId ) .some( ({ condition }: { condition: jsonLogic.RulesLogic }) => diff --git a/studio/hooks/misc/withAuth.tsx b/studio/hooks/misc/withAuth.tsx index b0757765064..80bee5614fc 100644 --- a/studio/hooks/misc/withAuth.tsx +++ b/studio/hooks/misc/withAuth.tsx @@ -1,13 +1,14 @@ -import { ComponentType, useEffect } from 'react' import Head from 'next/head' import { NextRouter, useRouter } from 'next/router' +import { ComponentType, useEffect } from 'react' -import { getReturnToPath, STORAGE_KEY } from 'lib/gotrue' -import { IS_PLATFORM } from 'lib/constants' -import { useStore, usePermissions } from 'hooks' -import Error500 from '../../pages/500' -import { NextPageWithLayout } from 'types' +import { usePermissionsQuery } from 'data/permissions/permissions-query' import { useProfileQuery } from 'data/profile/profile-query' +import { useStore } from 'hooks' +import { IS_PLATFORM } from 'lib/constants' +import { getReturnToPath, STORAGE_KEY } from 'lib/gotrue' +import { NextPageWithLayout } from 'types' +import Error500 from '../../pages/500' const PLATFORM_ONLY_PAGES = ['reports', 'settings'] @@ -39,15 +40,14 @@ export function withAuth( if (!app.organizations.isInitialized) app.organizations.load() if (!app.projects.isInitialized) app.projects.load() - mutatePermissions() }, }) - const { - permissions, - isLoading: isPermissionLoading, - mutate: mutatePermissions, - } = usePermissions(profile) + usePermissionsQuery({ + onSuccess(permissions) { + ui.setPermissions(permissions) + }, + }) const isAccessingBlockedPage = !IS_PLATFORM && PLATFORM_ONLY_PAGES.includes(page) const isRedirecting = @@ -55,18 +55,14 @@ export function withAuth( checkRedirectTo(isLoading, router, profile, error, redirectTo, redirectIfFound) useEffect(() => { - if (!isPermissionLoading) { - ui.setPermissions(permissions) - } - // This should run after setting store data if (isRedirecting) { router.push(redirectTo) } - }, [isLoading, isPermissionLoading, isRedirecting, permissions]) + }, [isRedirecting, redirectTo]) useEffect(() => { - if (!isLoading && router.isReady) { + if (router.isReady) { if (ref) { rootStore.setProjectRef(Array.isArray(ref) ? ref[0] : ref) } diff --git a/studio/hooks/queries/index.ts b/studio/hooks/queries/index.ts index 0722767ed0e..aa3cd3421f8 100644 --- a/studio/hooks/queries/index.ts +++ b/studio/hooks/queries/index.ts @@ -2,7 +2,6 @@ export * from './useJwtSecretUpdateStatus' export * from './useOrganizationDetail' export * from './useOrganizationRoles' export * from './useFreeProjectLimitCheck' -export * from './usePermissions' export * from './useProjectAuthConfig' export * from './useProjectSettings' export * from './useProjectSubscription' From d38c8dc1d5fd2bf2b5e0cf4a6f7e557e0ac46e52 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Fri, 25 Nov 2022 18:52:07 +0000 Subject: [PATCH 05/83] move useProjectSettings to react-query --- .../Functions/TerminalInstructions.tsx | 44 +++-- .../Home/NewProjectPanel/APIKeys.tsx | 41 ++--- .../interfaces/Settings/API/ServiceList.tsx | 40 ++--- .../CustomDomainConfig/CustomDomainConfig.tsx | 4 +- .../CustomDomainConfig/CustomDomainVerify.tsx | 4 +- .../layouts/StorageLayout/StorageLayout.tsx | 23 +-- .../ui/ProjectSettings/DisplayApiSettings.tsx | 161 ++++++++++++++++-- .../ProjectSettings/DisplayConfigSettings.tsx | 33 ++-- studio/data/config/keys.ts | 1 + studio/data/config/project-api-query.ts | 116 +++++++++++++ studio/data/config/project-settings-query.ts | 60 +++---- studio/hooks/queries/index.ts | 1 - studio/hooks/queries/useProjectSettings.ts | 31 ---- .../project/[ref]/functions/[id]/details.tsx | 23 ++- studio/pages/project/[ref]/settings/api.tsx | 2 +- 15 files changed, 398 insertions(+), 186 deletions(-) create mode 100644 studio/data/config/project-api-query.ts delete mode 100644 studio/hooks/queries/useProjectSettings.ts diff --git a/studio/components/interfaces/Functions/TerminalInstructions.tsx b/studio/components/interfaces/Functions/TerminalInstructions.tsx index 4bf07d8c62d..32d1176236b 100644 --- a/studio/components/interfaces/Functions/TerminalInstructions.tsx +++ b/studio/components/interfaces/Functions/TerminalInstructions.tsx @@ -1,12 +1,12 @@ -import { useRouter } from 'next/router' -import { Button, IconTerminal, IconMaximize2, IconMinimize2, IconBookOpen, IconCode } from 'ui' -import { useProjectSettings } from 'hooks' -import { useAccessTokens } from 'hooks/queries/useAccessTokens' -import { Commands } from './Functions.types' import CommandRender from 'components/interfaces/Functions/CommandRender' -import { FC, useState } from 'react' -import { useStore } from 'hooks' +import { useProjectApiQuery } from 'data/config/project-api-query' +import { useParams } from 'hooks' +import { useAccessTokens } from 'hooks/queries/useAccessTokens' import Link from 'next/link' +import { useRouter } from 'next/router' +import { FC, useState } from 'react' +import { Button, IconBookOpen, IconCode, IconMaximize2, IconMinimize2, IconTerminal } from 'ui' +import { Commands } from './Functions.types' interface Props { closable?: boolean @@ -14,21 +14,19 @@ interface Props { const TerminalInstructions: FC = ({ closable = false }) => { const router = useRouter() - const { ref } = router.query - const { ui } = useStore() - - const { tokens } = useAccessTokens() - const { services } = useProjectSettings(ref as string | undefined) + const { ref: projectRef } = useParams() const [showInstructions, setShowInstructions] = useState(!closable) - // Get the API service - const API_SERVICE_ID = 1 - const apiService = (services ?? []).find((x: any) => x.app.id == API_SERVICE_ID) - const apiKeys = apiService?.service_api_keys ?? [] - const anonKey = apiKeys.find((x: any) => x.name === 'anon key')?.api_key + const { tokens } = useAccessTokens() + + const { data: settings } = useProjectApiQuery({ + projectRef, + }) + + const anonKey = settings?.autoApiService.defaultApiKey + const endpoint = settings?.autoApiService.app_config.endpoint ?? '' - const endpoint = apiService?.app_config.endpoint ?? '' const endpointSections = endpoint.split('.') const functionsEndpoint = [ ...endpointSections.slice(0, 1), @@ -37,8 +35,8 @@ const TerminalInstructions: FC = ({ closable = false }) => { ].join('.') // get the .co or .net TLD from the restUrl - const restUrl = ui.selectedProject?.restUrl - const restUrlTld = new URL(restUrl as string).hostname.split('.').pop() + const restUrl = settings?.autoApiService.restUrl ?? '' + const restUrlTld = new URL(restUrl).hostname.split('.').pop() const commands: Commands[] = [ { @@ -54,20 +52,20 @@ const TerminalInstructions: FC = ({ closable = false }) => { comment: 'Create a function', }, { - command: `supabase functions deploy hello-world --project-ref ${ref}`, + command: `supabase functions deploy hello-world --project-ref ${projectRef}`, description: 'Deploys function at ./functions/hello-world/index.ts', jsx: () => { return ( <> supabase functions deploy hello-world - --project-ref {ref} + --project-ref {projectRef} ) }, comment: 'Deploy your function', }, { - command: `curl -L -X POST 'https://${ref}.functions.supabase.${restUrlTld}/hello-world' -H 'Authorization: Bearer ${ + command: `curl -L -X POST 'https://${projectRef}.functions.supabase.${restUrlTld}/hello-world' -H 'Authorization: Bearer ${ anonKey ?? '[YOUR ANON KEY]' }' --data '{"name":"Functions"}'`, description: 'Invokes the hello-world function', diff --git a/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx b/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx index a70a125cc0b..df8c4e89282 100644 --- a/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx +++ b/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx @@ -1,19 +1,18 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' import Link from 'next/link' import { useState } from 'react' -import { useRouter } from 'next/router' -import { Input, IconLoader, IconAlertCircle } from 'ui' -import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' -import { PermissionAction } from '@supabase/shared-types/out/constants' +import { IconAlertCircle, IconLoader, Input } from 'ui' -import { checkPermissions, useJwtSecretUpdateStatus, useProjectSettings } from 'hooks' -import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' import Snippets from 'components/to-be-cleaned/Docs/Snippets' -import Panel from 'components/ui/Panel' import SimpleCodeBlock from 'components/to-be-cleaned/SimpleCodeBlock' +import Panel from 'components/ui/Panel' +import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import { checkPermissions, useJwtSecretUpdateStatus, useParams } from 'hooks' +import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' const APIKeys = () => { - const router = useRouter() - const { ref } = router.query + const { ref: projectRef } = useParams() const availableLanguages = [ { name: 'Javascript', key: 'js' }, @@ -22,22 +21,26 @@ const APIKeys = () => { const [selectedLanguage, setSelectedLanguage] = useState(availableLanguages[0]) const { - services, + data: settings, isError: isProjectSettingsError, isLoading: isProjectSettingsLoading, - } = useProjectSettings(ref as string | undefined) + } = useProjectSettingsQuery({ + projectRef, + }) const { jwtSecretUpdateStatus, isError: isJwtSecretUpdateStatusError, isLoading: isJwtSecretUpdateStatusLoading, - }: any = useJwtSecretUpdateStatus(ref) + }: any = useJwtSecretUpdateStatus(projectRef) const canReadAPIKeys = checkPermissions(PermissionAction.READ, 'service_api_keys') // Get the API service - const apiService = (services ?? []).find((x: any) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID) - const apiConfig = apiService?.app_config ?? {} + const apiService = (settings?.services ?? []).find( + (x: any) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID + ) + const apiConfig = apiService?.app_config const apiKeys = apiService?.service_api_keys ?? [] // API keys should not be empty. However it can be populated with a delay on project creation @@ -45,7 +48,7 @@ const APIKeys = () => { const isNotUpdatingJwtSecret = jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated - const apiUrl = `https://${apiConfig.endpoint}` + const apiUrl = `https://${apiConfig?.endpoint ?? '-'}` const anonKey = apiKeys.find((key: any) => key.tags === 'anon') const clientInitSnippet: any = Snippets.init(apiUrl) @@ -110,7 +113,7 @@ const APIKeys = () => {

API Key

- {anonKey.tags?.split(',').map((x: any, i: number) => ( + {anonKey?.tags.split(',').map((x: any, i: number) => ( {x} @@ -120,7 +123,7 @@ const APIKeys = () => {
} copy={canReadAPIKeys && isNotUpdatingJwtSecret} - reveal={anonKey.tags !== 'anon' && canReadAPIKeys && isNotUpdatingJwtSecret} + reveal={anonKey?.tags !== 'anon' && canReadAPIKeys && isNotUpdatingJwtSecret} value={ !canReadAPIKeys ? 'You need additional permissions to view API keys' @@ -128,7 +131,7 @@ const APIKeys = () => { ? 'JWT secret update failed, new API key may have issues' : jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updating ? 'Updating JWT secret...' - : anonKey.api_key + : anonKey?.api_key ?? '-' } onChange={() => {}} descriptionText={ @@ -136,7 +139,7 @@ const APIKeys = () => { This key is safe to use in a browser if you have enabled Row Level Security (RLS) for your tables and configured policies. You may also use the service key which can be found{' '} - + here {' '} to bypass RLS. diff --git a/studio/components/interfaces/Settings/API/ServiceList.tsx b/studio/components/interfaces/Settings/API/ServiceList.tsx index f1dcce06dca..fec6b7bd493 100644 --- a/studio/components/interfaces/Settings/API/ServiceList.tsx +++ b/studio/components/interfaces/Settings/API/ServiceList.tsx @@ -1,33 +1,26 @@ -import { FC, useEffect, useRef } from 'react' -import { useRouter } from 'next/router' import { JwtSecretUpdateError, JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' +import { useEffect, useRef } from 'react' import { IconAlertCircle, Input } from 'ui' -import { - useStore, - useProjectSettings, - useProjectPostgrestConfig, - useJwtSecretUpdateStatus, -} from 'hooks' +import { useJwtSecretUpdateStatus, useParams, useProjectPostgrestConfig, useStore } from 'hooks' import Panel from 'components/ui/Panel' -import PostgrestConfig from './PostgrestConfig' import { DisplayApiSettings } from 'components/ui/ProjectSettings' +import { useProjectApiQuery } from 'data/config/project-api-query' import { JWT_SECRET_UPDATE_ERROR_MESSAGES } from './API.constants' import JWTSettings from './JWTSettings' +import PostgrestConfig from './PostgrestConfig' -interface Props { - projectRef: string -} - -const ServiceList: FC = ({ projectRef }) => { +const ServiceList = () => { const { ui } = useStore() - const router = useRouter() - const { ref } = router.query - const { services, isError, mutateSettings } = useProjectSettings(ref as string | undefined) - const { mutateConfig } = useProjectPostgrestConfig(ref as string | undefined) - const { jwtSecretUpdateError, jwtSecretUpdateStatus }: any = useJwtSecretUpdateStatus(ref) + const { ref: projectRef } = useParams() + const { data: settings, isError } = useProjectApiQuery({ + projectRef, + }) + + const { mutateConfig } = useProjectPostgrestConfig(projectRef as string | undefined) + const { jwtSecretUpdateError, jwtSecretUpdateStatus }: any = useJwtSecretUpdateStatus(projectRef) const previousJwtSecretUpdateStatus = useRef() const { Failed, Updated, Updating } = JwtSecretUpdateStatus @@ -39,7 +32,7 @@ const ServiceList: FC = ({ projectRef }) => { switch (jwtSecretUpdateStatus) { case Updated: mutateConfig() - mutateSettings() + // mutateSettings() ui.setNotification({ category: 'success', message: 'Successfully updated JWT secret' }) break case Failed: @@ -54,10 +47,7 @@ const ServiceList: FC = ({ projectRef }) => { previousJwtSecretUpdateStatus.current = jwtSecretUpdateStatus }, [jwtSecretUpdateStatus]) - // Get the API service - const API_SERVICE_ID = 1 - const apiService = services ? services.find((x: any) => x.app.id == API_SERVICE_ID) : {} - const apiConfig = apiService?.app_config + const endpoint = settings?.autoApiService.app_config.endpoint ?? '' return ( <> @@ -78,7 +68,7 @@ const ServiceList: FC = ({ projectRef }) => { readOnly disabled className="input-mono" - value={`https://${apiConfig?.endpoint ?? '-'}`} + value={`https://${endpoint ?? '-'}`} descriptionText="A RESTful endpoint for querying and managing your database." layout="horizontal" /> diff --git a/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx b/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx index dde95501290..ae2222088a1 100644 --- a/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx +++ b/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx @@ -3,7 +3,7 @@ import { observer } from 'mobx-react-lite' import { IconAlertCircle } from 'ui' import { useParams, useStore } from 'hooks' -import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import { useProjectApiQuery } from 'data/config/project-api-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import Panel from 'components/ui/Panel' import { FormHeader } from 'components/ui/Forms' @@ -20,7 +20,7 @@ const CustomDomainConfig = () => { const { ref } = useParams() const tier = ui.selectedProject?.subscription_tier - const { isLoading: isSettingsLoading, data: settings } = useProjectSettingsQuery({ + const { isLoading: isSettingsLoading, data: settings } = useProjectApiQuery({ projectRef: ref, }) diff --git a/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx b/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx index c8dee15035a..93e2d0c1e8e 100644 --- a/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx +++ b/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx @@ -4,7 +4,7 @@ import { observer } from 'mobx-react-lite' import { Button, IconAlertCircle, IconExternalLink, IconHelpCircle, IconRefreshCw } from 'ui' import { useStore } from 'hooks' -import { ProjectSettingsResponse } from 'data/config/project-settings-query' +import { ProjectApiResponse } from 'data/config/project-api-query' import { CustomDomainResponse } from 'data/custom-domains/custom-domains-query' import { useCustomDomainDeleteMutation } from 'data/custom-domains/custom-domains-delete-mutation' import { useCustomDomainReverifyMutation } from 'data/custom-domains/custom-domains-reverify-mutation' @@ -15,7 +15,7 @@ import InformationBox from 'components/ui/InformationBox' export type CustomDomainVerifyProps = { projectRef?: string customDomain: CustomDomainResponse - settings?: ProjectSettingsResponse + settings?: ProjectApiResponse } const CustomDomainVerify = ({ projectRef, customDomain, settings }: CustomDomainVerifyProps) => { diff --git a/studio/components/layouts/StorageLayout/StorageLayout.tsx b/studio/components/layouts/StorageLayout/StorageLayout.tsx index 6f6c3a13491..d259c3b5d8a 100644 --- a/studio/components/layouts/StorageLayout/StorageLayout.tsx +++ b/studio/components/layouts/StorageLayout/StorageLayout.tsx @@ -3,7 +3,7 @@ import { useRouter } from 'next/router' import { find, filter, get as _get } from 'lodash' import { observer } from 'mobx-react-lite' -import { useProjectSettings, useStore, withAuth } from 'hooks' +import { useParams, useStore, withAuth } from 'hooks' import BaseLayout from 'components/layouts' import ProjectLayout from '../ProjectLayout/ProjectLayout' import StorageMenu from './StorageMenu' @@ -13,6 +13,7 @@ import CreateBucketModal from 'components/to-be-cleaned/Storage/CreateBucketModa import DeleteBucketModal from 'components/to-be-cleaned/Storage/DeleteBucketModal' import ToggleBucketPublicModal from 'components/to-be-cleaned/Storage/ToggleBucketPublicModal' import NoPermission from 'components/ui/NoPermission' +import { useProjectApiQuery } from 'data/config/project-api-query' interface Props { title: string @@ -22,7 +23,7 @@ interface Props { const StorageLayout: FC = ({ title, children }) => { const { ui, meta } = useStore() const router = useRouter() - const { ref } = router.query + const { ref: projectRef } = useParams() const storageExplorerStore = useStorageStore() const { @@ -39,20 +40,22 @@ const StorageLayout: FC = ({ title, children }) => { buckets, } = storageExplorerStore || {} - const { services, isLoading } = useProjectSettings(ref as string | undefined) - const apiService = find(services ?? [], (service) => service.app.id === 1) - const projectUrl = apiService?.app_config?.endpoint ?? '' - const serviceKey = find(apiService?.service_api_keys ?? [], (key) => key.tags === 'service_role') - const canAccessStorage = !isLoading && services && serviceKey + const { data: settings, isLoading } = useProjectApiQuery({ + projectRef, + }) + + const projectUrl = settings?.autoApiService.app_config.endpoint ?? '' + const serviceKey = settings?.autoApiService.serviceApiKey + const canAccessStorage = !isLoading && settings && serviceKey useEffect(() => { - if (!isLoading && services) initializeStorageStore() + if (!isLoading && settings) initializeStorageStore() }, [isLoading]) const initializeStorageStore = async () => { if (projectUrl) { if (serviceKey) { - storageExplorerStore.initStore(ref, projectUrl, serviceKey.api_key) + storageExplorerStore.initStore(projectRef, projectUrl, serviceKey) await storageExplorerStore.fetchBuckets() } } else { @@ -67,7 +70,7 @@ const StorageLayout: FC = ({ title, children }) => { const onSelectCreateBucket = async (bucketName: string, isPublic: boolean) => { const bucket = await createBucket(bucketName, isPublic) - if (bucket.name) router.push(`/project/${ref}/storage/buckets/${bucket.name}`) + if (bucket.name) router.push(`/project/${projectRef}/storage/buckets/${bucket.name}`) } const onSelectDeleteBucket = async (bucket: any) => { diff --git a/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx b/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx index c691e0d5730..cb7dc29a9d4 100644 --- a/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx +++ b/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx @@ -1,34 +1,34 @@ -import { useRouter } from 'next/router' -import { IconAlertCircle, IconLoader, Input } from 'ui' -import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' +import { IconAlertCircle, IconLoader, Input } from 'ui' -import { checkPermissions, useJwtSecretUpdateStatus, useProjectSettings } from 'hooks' -import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' import Panel from 'components/ui/Panel' +import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import { checkPermissions, useJwtSecretUpdateStatus, useParams } from 'hooks' +import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' const DisplayApiSettings = () => { - const router = useRouter() - const { ref } = router.query + const { ref: projectRef } = useParams() const { - services, - error: projectSettingsError, + data: settings, isError: isProjectSettingsError, isLoading: isProjectSettingsLoading, - } = useProjectSettings(ref as string | undefined) + } = useProjectSettingsQuery({ projectRef }) const { jwtSecretUpdateStatus, isError: isJwtSecretUpdateStatusError, isLoading: isJwtSecretUpdateStatusLoading, - }: any = useJwtSecretUpdateStatus(ref) + }: any = useJwtSecretUpdateStatus(projectRef) const canReadAPIKeys = checkPermissions(PermissionAction.READ, 'service_api_keys') const isNotUpdatingJwtSecret = jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated // Get the API service - const apiService = (services ?? []).find((x: any) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID) + const apiService = (settings?.services ?? []).find( + (x: any) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID + ) const apiKeys = apiService?.service_api_keys ?? [] // api keys should not be empty. However it can be populated with a delay on project creation const isApiKeysEmpty = apiKeys.length === 0 @@ -117,3 +117,140 @@ const DisplayApiSettings = () => { ) } export default DisplayApiSettings + +/* +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' +import { IconAlertCircle, IconLoader, Input } from 'ui' + +import Panel from 'components/ui/Panel' +import { useProjectApiQuery } from 'data/config/project-api-query' +import { checkPermissions, useJwtSecretUpdateStatus, useParams } from 'hooks' + +const DisplayApiSettings = () => { + const { ref: projectRef } = useParams() + const { + data: settings, + isError: isProjectSettingsError, + isLoading: isProjectSettingsLoading, + } = useProjectApiQuery({ + projectRef, + }) + + const { + jwtSecretUpdateStatus, + isError: isJwtSecretUpdateStatusError, + isLoading: isJwtSecretUpdateStatusLoading, + }: any = useJwtSecretUpdateStatus(projectRef) + + const canReadAPIKeys = checkPermissions(PermissionAction.READ, 'service_api_keys') + + const isNotUpdatingJwtSecret = + jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated + // Get the API service + + const apiKeys = settings?.autoApiService.service_api_keys ?? [] + // api keys should not be empty. However it can be populated with a delay on project creation + const isApiKeysEmpty = apiKeys.length === 0 + + const anonKey = settings?.autoApiService.defaultApiKey + const serviceKey = settings?.autoApiService.serviceApiKey + + return ( + +
Project API keys
+

+ Your API is secured behind an API gateway which requires an API Key for every request. +
+ You can use the keys below to use Supabase client libraries. +

+
+ } + > + {isProjectSettingsError || isJwtSecretUpdateStatusError ? ( +
+ +

+ {isProjectSettingsError ? 'Failed to retrieve API keys' : 'Failed to update JWT secret'} +

+
+ ) : isApiKeysEmpty || isProjectSettingsLoading || isJwtSecretUpdateStatusLoading ? ( +
+ +

+ {isProjectSettingsLoading || isApiKeysEmpty + ? 'Retrieving API keys' + : 'JWT secret is being updated'} +

+
+ ) : ( + <> + + + anon + public + + } + copy={canReadAPIKeys && isNotUpdatingJwtSecret} + value={ + !canReadAPIKeys + ? 'You need additional permissions to view API keys' + : jwtSecretUpdateStatus === JwtSecretUpdateStatus.Failed + ? 'JWT secret update failed, new API key may have issues' + : jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updating + ? 'Updating JWT secret...' + : anonKey + } + onChange={() => {}} + descriptionText="This key is safe to use in a browser if you have enabled Row Level Security for your tables and configured policies." + /> + + + + + service_role + secret + + } + copy={canReadAPIKeys && isNotUpdatingJwtSecret} + reveal={canReadAPIKeys && isNotUpdatingJwtSecret} + value={ + !canReadAPIKeys + ? 'You need additional permissions to view API keys' + : jwtSecretUpdateStatus === JwtSecretUpdateStatus.Failed + ? 'JWT secret update failed, new API key may have issues' + : jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updating + ? 'Updating JWT secret...' + : serviceKey + } + onChange={() => {}} + descriptionText="This key has the ability to bypass Row Level Security. Never share it publicly." + /> + + + )} + + ) +} +export default DisplayApiSettings + +*/ diff --git a/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx b/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx index d43f8a3d478..ce804ef8d81 100644 --- a/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx +++ b/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx @@ -1,32 +1,35 @@ -import { FC } from 'react' -import { useRouter } from 'next/router' -import { Input, IconAlertCircle, IconLoader } from 'ui' import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' -import { useJwtSecretUpdateStatus, useProjectSettings } from 'hooks' -import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' import Panel from 'components/ui/Panel' +import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import { useJwtSecretUpdateStatus, useParams } from 'hooks' +import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' +import { FC } from 'react' +import { IconAlertCircle, IconLoader, Input } from 'ui' const DisplayConfigSettings = () => { - const router = useRouter() - const { ref } = router.query + const { ref: projectRef } = useParams() const { - project, - services, + data: settings, isLoading: isProjectSettingsLoading, isError: isProjectSettingsError, - } = useProjectSettings(ref as string | undefined) + } = useProjectSettingsQuery({ + projectRef, + }) + const { isError: isJwtSecretUpdateStatusError, isLoading: isJwtSecretUpdateStatusLoading, jwtSecretUpdateStatus, - }: any = useJwtSecretUpdateStatus(ref) + }: any = useJwtSecretUpdateStatus(projectRef) const isNotUpdatingJwtSecret = jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated // Get the API service - const jwtSecret = project?.jwt_secret ?? '' - const apiService = (services ?? []).find((x: any) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID) - const apiConfig = apiService?.app_config ?? {} + const jwtSecret = settings?.project.jwt_secret ?? '' + const apiService = (settings?.services ?? []).find( + (x: any) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID + ) + const apiConfig = apiService?.app_config return ( @@ -55,7 +58,7 @@ const DisplayConfigSettings = () => { copy disabled className="input-mono" - value={`https://${apiConfig.endpoint}`} + value={`https://${apiConfig?.endpoint ?? '-'}`} descriptionText="A RESTful endpoint for querying and managing your database." layout="horizontal" /> diff --git a/studio/data/config/keys.ts b/studio/data/config/keys.ts index 875848a4247..baa6c204ebd 100644 --- a/studio/data/config/keys.ts +++ b/studio/data/config/keys.ts @@ -1,3 +1,4 @@ export const configKeys = { settings: (projectRef: string | undefined) => ['projects', projectRef, 'settings'] as const, + api: (projectRef: string | undefined) => ['projects', projectRef, 'settings', 'api'] as const, } diff --git a/studio/data/config/project-api-query.ts b/studio/data/config/project-api-query.ts new file mode 100644 index 00000000000..11614a1ed4d --- /dev/null +++ b/studio/data/config/project-api-query.ts @@ -0,0 +1,116 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { configKeys } from './keys' + +export type ProjectApiVariables = { + projectRef?: string +} + +export type Project = { + id: number + name: string + ref: string + status: string + db_host: string + db_name: string + db_user: string + db_schema: string + db_port: number + db_ssl: boolean + services: Service[] +} + +export type App = { + id: number + name: string +} + +export type AppConfig = { + endpoint: string + db_schema: string + realtime_multitenant_enabled: boolean +} + +export type Service = { + id: number + name: string + app_config: AppConfig + app: App + service_api_keys: ServiceApiKey[] +} + +export type ServiceApiKey = { + api_key_encrypted: string + tags: string + name: string +} + +export type AutoApiService = Service & { + endpoint: string + restUrl: string + project: Project + defaultApiKey: string + serviceApiKey: string +} + +export type ProjectApiResponse = { + project: Project + autoApiService: AutoApiService +} + +export async function getProjectApi({ projectRef }: ProjectApiVariables, signal?: AbortSignal) { + if (!projectRef) { + throw new Error('projectRef is required') + } + + const response = await get(`${API_URL}/props/project/${projectRef}/api`, { + signal, + }) + if (response.error) { + throw response.error + } + + return response as ProjectApiResponse +} + +export type ProjectApiData = Awaited> +export type ProjectApiError = unknown + +export const useProjectApiQuery = ( + { projectRef }: ProjectApiVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + configKeys.api(projectRef), + ({ signal }) => getProjectApi({ projectRef }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + refetchInterval(data, query) { + if (!data) { + return false + } + + const { autoApiService } = data as unknown as ProjectApiData + + const apiKeys = autoApiService?.service_api_keys ?? [] + const interval = apiKeys.length === 0 ? 2000 : 0 + + return interval + }, + ...options, + } + ) + +export const useProjectApiPrefetch = ({ projectRef }: ProjectApiVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (projectRef) { + client.prefetchQuery(configKeys.api(projectRef), ({ signal }) => + getProjectApi({ projectRef }, signal) + ) + } + }, [projectRef]) +} diff --git a/studio/data/config/project-settings-query.ts b/studio/data/config/project-settings-query.ts index ad473e32e38..4d8eb6bf769 100644 --- a/studio/data/config/project-settings-query.ts +++ b/studio/data/config/project-settings-query.ts @@ -1,6 +1,6 @@ import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' +import { API_URL, DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' import { useCallback } from 'react' import { configKeys } from './keys' @@ -13,24 +13,17 @@ export type Project = { name: string ref: string status: string - db_host: string - db_name: string - db_user: string - db_schema: string + inserted_at: string + db_dns_name: string db_port: number + db_name: string db_ssl: boolean + db_host: string + db_user: string + cloud_provider: string + region: string services: Service[] -} - -export type App = { - id: number - name: string -} - -export type AppConfig = { - endpoint: string - db_schema: string - realtime_multitenant_enabled: boolean + jwt_secret: string } export type Service = { @@ -41,23 +34,27 @@ export type Service = { service_api_keys: ServiceApiKey[] } +export type AppConfig = { + endpoint: string + db_schema: string + realtime_multitenant_enabled: boolean +} + +export type App = { + id: number + name: string +} + export type ServiceApiKey = { api_key_encrypted: string tags: string name: string -} - -export type AutoApiService = Service & { - endpoint: string - restUrl: string - project: Project - defaultApiKey: string - serviceApiKey: string + api_key: string } export type ProjectSettingsResponse = { project: Project - autoApiService: AutoApiService + services: Service[] } export async function getProjectSettings( @@ -68,7 +65,7 @@ export async function getProjectSettings( throw new Error('projectRef is required') } - const response = await get(`${API_URL}/props/project/${projectRef}/api`, { + const response = await get(`${API_URL}/props/project/${projectRef}/settings`, { signal, }) if (response.error) { @@ -94,13 +91,10 @@ export const useProjectSettingsQuery = ( { enabled: enabled && typeof projectRef !== 'undefined', refetchInterval(data, query) { - if (!data) { - return false - } - - const { autoApiService } = data as unknown as ProjectSettingsData - - const apiKeys = autoApiService?.service_api_keys ?? [] + const apiService = ( + (data as unknown as ProjectSettingsData | undefined)?.services ?? [] + ).find((x) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID) + const apiKeys = apiService?.service_api_keys ?? [] const interval = apiKeys.length === 0 ? 2000 : 0 return interval diff --git a/studio/hooks/queries/index.ts b/studio/hooks/queries/index.ts index aa3cd3421f8..18887b214f3 100644 --- a/studio/hooks/queries/index.ts +++ b/studio/hooks/queries/index.ts @@ -3,7 +3,6 @@ export * from './useOrganizationDetail' export * from './useOrganizationRoles' export * from './useFreeProjectLimitCheck' export * from './useProjectAuthConfig' -export * from './useProjectSettings' export * from './useProjectSubscription' export * from './useSubscriptionStats' export * from './useProjectPostgrestConfig' diff --git a/studio/hooks/queries/useProjectSettings.ts b/studio/hooks/queries/useProjectSettings.ts deleted file mode 100644 index cccebec4bb1..00000000000 --- a/studio/hooks/queries/useProjectSettings.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { get } from 'lib/common/fetch' -import { API_URL, DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' -import useSWR from 'swr' - -export function useProjectSettings(ref?: string) { - const url = `${API_URL}/props/project/${ref}/settings` - const { data, error, mutate } = useSWR(ref ? url : null, get, { - /** - * on project creation, the service_api_keys will be populated with a delay - * check for data.services.service_api_keys, return a valid refresh interval if it's empty - */ - refreshInterval: function (data: any) { - const apiService = (data?.services ?? []).find( - (x: any) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID - ) - const apiKeys = apiService?.service_api_keys ?? [] - const interval = apiKeys.length === 0 ? 2000 : 0 - return interval - }, - }) - const anyError = data?.error || error - - return { - mutateSettings: mutate, - project: anyError ? undefined : data?.project, - services: anyError ? undefined : data?.services, - error: anyError, - isLoading: !anyError && !data, - isError: !!anyError, - } -} diff --git a/studio/pages/project/[ref]/functions/[id]/details.tsx b/studio/pages/project/[ref]/functions/[id]/details.tsx index 1f17b634105..8f4aa672881 100644 --- a/studio/pages/project/[ref]/functions/[id]/details.tsx +++ b/studio/pages/project/[ref]/functions/[id]/details.tsx @@ -1,19 +1,18 @@ import dayjs from 'dayjs' import { useEffect, useState } from 'react' -import { useRouter } from 'next/router' import { observer } from 'mobx-react-lite' import { IconGlobe, IconTerminal } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' import { NextPageWithLayout } from 'types' -import { checkPermissions, useProjectSettings, useStore } from 'hooks' +import { checkPermissions, useParams, useStore } from 'hooks' import FunctionsLayout from 'components/layouts/FunctionsLayout' import CommandRender from 'components/interfaces/Functions/CommandRender' import NoPermission from 'components/ui/NoPermission' +import { useProjectApiQuery } from 'data/config/project-api-query' const PageLayout: NextPageWithLayout = () => { - const router = useRouter() - const { ref, id } = router.query + const { ref: projectRef, id } = useParams() const { functions, ui } = useStore() @@ -23,6 +22,10 @@ const PageLayout: NextPageWithLayout = () => { setSelectedFunction(functions.byId(id)) }, [functions.isLoaded, ui.selectedProject]) + const { data: settings } = useProjectApiQuery({ + projectRef, + }) + // get the .co or .net TLD from the restUrl const restUrl = ui.selectedProject?.restUrl const restUrlTld = new URL(restUrl as string).hostname.split('.').pop() @@ -95,16 +98,12 @@ const PageLayout: NextPageWithLayout = () => { }, ] - const { services } = useProjectSettings(ref as string | undefined) - const API_SERVICE_ID = 1 // Get the API service - const apiService = (services ?? []).find((x: any) => x.app.id == API_SERVICE_ID) - const apiKeys = apiService?.service_api_keys ?? [] - const anonKey = apiKeys.find((x: any) => x.name === 'anon key')?.api_key + const anonKey = settings?.autoApiService.defaultApiKey - const endpoint = apiService?.app_config.endpoint ?? '' + const endpoint = settings?.autoApiService.app_config.endpoint ?? '' const endpointSections = endpoint.split('.') const functionsEndpoint = [ ...endpointSections.slice(0, 1), @@ -114,7 +113,7 @@ const PageLayout: NextPageWithLayout = () => { const invokeCommands: any = [ { - command: `curl -L -X POST 'https://${ref}.functions.supabase.${restUrlTld}/${ + command: `curl -L -X POST 'https://${projectRef}.functions.supabase.${restUrlTld}/${ selectedFunction?.slug }' -H 'Authorization: Bearer ${anonKey ?? '[YOUR ANON KEY]'}' --data '{"name":"Functions"}'`, description: 'Invokes the hello function', @@ -177,7 +176,7 @@ const PageLayout: NextPageWithLayout = () => {
Endpoint URL
- {`https://${ref}.functions.supabase.co/${selectedFunction?.slug}`} + {`https://${projectRef}.functions.supabase.co/${selectedFunction?.slug}`}
diff --git a/studio/pages/project/[ref]/settings/api.tsx b/studio/pages/project/[ref]/settings/api.tsx index 63af221d05f..618c9498d2c 100644 --- a/studio/pages/project/[ref]/settings/api.tsx +++ b/studio/pages/project/[ref]/settings/api.tsx @@ -38,7 +38,7 @@ const ApiSettings: NextPageWithLayout = () => { return (
- +
) From 3a90fdc2b741ca0164f4a98acc0a31e6d2a4506e Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Mon, 28 Nov 2022 20:40:06 +0000 Subject: [PATCH 06/83] move organization SWRs to react-query --- .../TeamSettings/InviteMemberButton.tsx | 68 +++--- .../TeamSettings/MemberActions.tsx | 198 +++++++++--------- .../Organization/TeamSettings/MembersView.tsx | 179 +++++++--------- .../ui/ProjectSettings/DisplayApiSettings.tsx | 137 ------------ studio/data/organizations/keys.ts | 4 + .../organization-detail-query.ts | 96 +++++++++ .../organization-member-delete-mutation.ts | 50 +++++ ...anization-member-invite-create-mutation.ts | 63 ++++++ ...anization-member-invite-delete-mutation.ts | 58 +++++ .../organization-member-update-mutation.ts | 54 +++++ .../organizations/organization-roles-query.ts | 63 ++++++ studio/hooks/queries/index.ts | 2 - studio/hooks/queries/useOrganizationDetail.ts | 59 ------ studio/hooks/queries/useOrganizationRoles.ts | 22 -- studio/pages/org/[slug]/settings.tsx | 20 +- 15 files changed, 609 insertions(+), 464 deletions(-) create mode 100644 studio/data/organizations/keys.ts create mode 100644 studio/data/organizations/organization-detail-query.ts create mode 100644 studio/data/organizations/organization-member-delete-mutation.ts create mode 100644 studio/data/organizations/organization-member-invite-create-mutation.ts create mode 100644 studio/data/organizations/organization-member-invite-delete-mutation.ts create mode 100644 studio/data/organizations/organization-member-update-mutation.ts create mode 100644 studio/data/organizations/organization-roles-query.ts delete mode 100644 studio/hooks/queries/useOrganizationDetail.ts delete mode 100644 studio/hooks/queries/useOrganizationRoles.ts diff --git a/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index 092a893902b..00ce2cba70c 100644 --- a/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -1,15 +1,13 @@ import { isNil } from 'lodash' -import { useRouter } from 'next/router' import { FC, useEffect, useState } from 'react' import { object, string } from 'yup' import * as Tooltip from '@radix-ui/react-tooltip' import { Button, Form, IconMail, Input, Modal, Select } from 'ui' import { Member, User, Role } from 'types' -import { checkPermissions, useFlag, useOrganizationDetail, useStore } from 'hooks' -import { post } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' +import { checkPermissions, useParams, useStore } from 'hooks' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useOrganizationMemberInviteCreateMutation } from 'data/organizations/organization-member-invite-create-mutation' interface Props { user: User @@ -20,13 +18,9 @@ interface Props { const InviteMemberButton: FC = ({ user, members = [], roles = [], rolesAddable = [] }) => { const { ui } = useStore() - const router = useRouter() - const { slug } = router.query - - const enablePermissions = useFlag('enablePermissions') + const { slug } = useParams() const [isOpen, setIsOpen] = useState(false) - const { mutateOrgMembers } = useOrganizationDetail((slug as string) || '') const canInviteMembers = roles.some(({ id: role_id }) => checkPermissions(PermissionAction.CREATE, 'user_invites', { resource: { role_id } }) @@ -39,7 +33,13 @@ const InviteMemberButton: FC = ({ user, members = [], roles = [], rolesAd role: string().required('Role is required'), }) + const { mutateAsync } = useOrganizationMemberInviteCreateMutation() + const onInviteMember = async (values: any, { setSubmitting, resetForm }: any) => { + if (!slug) { + throw new Error('slug is required') + } + const existingMember = members.find( (member) => member.primary_email === values.email.toLowerCase() ) @@ -57,39 +57,31 @@ const InviteMemberButton: FC = ({ user, members = [], roles = [], rolesAd } } - const roleId = enablePermissions - ? Number(values.role) - : roles.find((role) => role.name === 'Developer')?.id ?? roles[0].id + const roleId = Number(values.role) setSubmitting(true) - const response = await post(`${API_URL}/organizations/${slug}/members/invite`, { - invited_email: values.email.toLowerCase(), - owner_id: user.id, - ...(enablePermissions ? { role_id: roleId } : {}), - }) + try { + const response = await mutateAsync({ + slug, + invitedEmail: values.email, + ownerId: user.id, + roleId, + }) - if (response.error) { + if (isNil(response)) { + ui.setNotification({ category: 'error', message: 'Failed to add member' }) + } else { + ui.setNotification({ category: 'success', message: 'Successfully added new member.' }) + + setIsOpen(!isOpen) + resetForm({ initialValues: { ...initialValues, role: roleId } }) + } + } catch (error: any) { ui.setNotification({ category: 'error', - message: `Failed to add member: ${response.error.message}`, + message: `Failed to add member: ${error.message}`, }) - } else if (isNil(response)) { - ui.setNotification({ category: 'error', message: 'Failed to add member' }) - } else { - const newMember: Member = { - id: 0, - invited_id: response.invited_id, - invited_at: response.invited_at, - primary_email: response.invited_email, - username: response.invited_email[0], - role_ids: [response.role_id], - } - mutateOrgMembers([...members, newMember]) - ui.setNotification({ category: 'success', message: 'Successfully added new member.' }) - - setIsOpen(!isOpen) - resetForm({ initialValues: { ...initialValues, role: roleId } }) } setSubmitting(false) @@ -141,9 +133,7 @@ const InviteMemberButton: FC = ({ user, members = [], roles = [], rolesAd }, [roles]) const selectedRole = roles.find((role) => role.id === Number(values.role)) - const invalidRoleSelected = enablePermissions - ? values.role && !rolesAddable.includes(Number(values.role)) - : false + const invalidRoleSelected = values.role && !rolesAddable.includes(Number(values.role)) return ( <> @@ -151,7 +141,7 @@ const InviteMemberButton: FC = ({ user, members = [], roles = [], rolesAd
- {roles && enablePermissions && ( + {roles && ( - anon - public - - } - copy={canReadAPIKeys && isNotUpdatingJwtSecret} - value={ - !canReadAPIKeys - ? 'You need additional permissions to view API keys' - : jwtSecretUpdateStatus === JwtSecretUpdateStatus.Failed - ? 'JWT secret update failed, new API key may have issues' - : jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updating - ? 'Updating JWT secret...' - : anonKey - } - onChange={() => {}} - descriptionText="This key is safe to use in a browser if you have enabled Row Level Security for your tables and configured policies." - /> - - - - - service_role - secret - - } - copy={canReadAPIKeys && isNotUpdatingJwtSecret} - reveal={canReadAPIKeys && isNotUpdatingJwtSecret} - value={ - !canReadAPIKeys - ? 'You need additional permissions to view API keys' - : jwtSecretUpdateStatus === JwtSecretUpdateStatus.Failed - ? 'JWT secret update failed, new API key may have issues' - : jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updating - ? 'Updating JWT secret...' - : serviceKey - } - onChange={() => {}} - descriptionText="This key has the ability to bypass Row Level Security. Never share it publicly." - /> - - - )} - - ) -} -export default DisplayApiSettings - -*/ diff --git a/studio/data/organizations/keys.ts b/studio/data/organizations/keys.ts new file mode 100644 index 00000000000..11952afb649 --- /dev/null +++ b/studio/data/organizations/keys.ts @@ -0,0 +1,4 @@ +export const organizationKeys = { + detail: (slug: string | undefined) => ['organizations', slug, 'detail'] as const, + roles: (slug: string | undefined) => ['organizations', slug, 'roles'] as const, +} diff --git a/studio/data/organizations/organization-detail-query.ts b/studio/data/organizations/organization-detail-query.ts new file mode 100644 index 00000000000..b540c140563 --- /dev/null +++ b/studio/data/organizations/organization-detail-query.ts @@ -0,0 +1,96 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { organizationKeys } from './keys' + +export type OrganizationDetailVariables = { + slug?: string +} + +export type Member = { + gotrue_id?: string + primary_email: string + role_ids: number[] + username: string + is_owner?: boolean + invited_at?: string + invited_id?: number +} + +export type OrganizationDetailResponse = { + members: Member[] +} + +export async function getOrganizationDetail( + { slug }: OrganizationDetailVariables, + signal?: AbortSignal +) { + if (!slug) { + throw new Error('slug is required') + } + + let [data, inviteData] = await Promise.all([ + get(`${API_URL}/organizations/${slug}/members?member_roles`, { + signal, + }), + get(`${API_URL}/organizations/${slug}/members/invite`, { + signal, + }), + ]) + if (data.error) { + throw data.error + } + if (inviteData.error) { + throw inviteData.error + } + + if (data && inviteData && inviteData.length > 0) { + // Remap invite data to look like existing members data + const invitedMembers = inviteData.map((x: any) => { + const member = { + is_owner: false, + invited_at: x.invited_at, + invited_id: x.invited_id, + username: x.invited_email.slice(0, 1), + primary_email: x.invited_email, + } + return { ...member, role_ids: [x.role_id] } + }) + + data = [...data, ...invitedMembers] + } + + return { members: data } as OrganizationDetailResponse +} + +export type OrganizationDetailData = Awaited> +export type OrganizationDetailError = unknown + +export const useOrganizationDetailQuery = ( + { slug }: OrganizationDetailVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + organizationKeys.detail(slug), + ({ signal }) => getOrganizationDetail({ slug }, signal), + { + enabled: enabled && typeof slug !== 'undefined', + ...options, + } + ) + +export const useOrganizationDetailPrefetch = ({ slug }: OrganizationDetailVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (slug) { + client.prefetchQuery(organizationKeys.detail(slug), ({ signal }) => + getOrganizationDetail({ slug }, signal) + ) + } + }, [slug]) +} diff --git a/studio/data/organizations/organization-member-delete-mutation.ts b/studio/data/organizations/organization-member-delete-mutation.ts new file mode 100644 index 00000000000..73a8e43f98c --- /dev/null +++ b/studio/data/organizations/organization-member-delete-mutation.ts @@ -0,0 +1,50 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { delete_ } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { organizationKeys } from './keys' + +export type OrganizationMemberDeleteVariables = { + slug: string + gotrueId: string +} + +export async function deleteOrganizationMember({ + slug, + gotrueId, +}: OrganizationMemberDeleteVariables) { + const response = await delete_(`${API_URL}/organizations/${slug}/members/${gotrueId}`) + if (response.error) { + throw response.error + } + + return response +} + +type OrganizationMemberDeleteData = Awaited> + +export const useOrganizationMemberDeleteMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => deleteOrganizationMember(vars), + { + async onSuccess(data, variables, context) { + const { slug } = variables + + await Promise.all([ + queryClient.invalidateQueries(organizationKeys.detail(slug)), + queryClient.invalidateQueries(organizationKeys.roles(slug)), + ]) + + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/data/organizations/organization-member-invite-create-mutation.ts b/studio/data/organizations/organization-member-invite-create-mutation.ts new file mode 100644 index 00000000000..b07df4f6756 --- /dev/null +++ b/studio/data/organizations/organization-member-invite-create-mutation.ts @@ -0,0 +1,63 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { organizationKeys } from './keys' + +export type OrganizationMemberInviteCreateVariables = { + slug: string + invitedEmail: string + ownerId: number + roleId: number +} + +export async function createOrganizationMemberInvite({ + slug, + invitedEmail, + ownerId, + roleId, +}: OrganizationMemberInviteCreateVariables) { + const response = await post(`${API_URL}/organizations/${slug}/members/invite`, { + invited_email: invitedEmail, + owner_id: ownerId, + role_id: roleId, + }) + if (response.error) { + throw response.error + } + + return response +} + +type OrganizationMemberInviteCreateData = Awaited> + +export const useOrganizationMemberInviteCreateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions< + OrganizationMemberInviteCreateData, + unknown, + OrganizationMemberInviteCreateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + OrganizationMemberInviteCreateData, + unknown, + OrganizationMemberInviteCreateVariables + >((vars) => createOrganizationMemberInvite(vars), { + async onSuccess(data, variables, context) { + const { slug } = variables + + await Promise.all([ + queryClient.invalidateQueries(organizationKeys.detail(slug)), + queryClient.invalidateQueries(organizationKeys.roles(slug)), + ]) + + await onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/studio/data/organizations/organization-member-invite-delete-mutation.ts b/studio/data/organizations/organization-member-invite-delete-mutation.ts new file mode 100644 index 00000000000..a801f9795f0 --- /dev/null +++ b/studio/data/organizations/organization-member-invite-delete-mutation.ts @@ -0,0 +1,58 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { delete_ } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { organizationKeys } from './keys' + +export type OrganizationMemberInviteDeleteVariables = { + slug: string + invitedId: number +} + +export async function deleteOrganizationMemberInvite({ + slug, + invitedId, +}: OrganizationMemberInviteDeleteVariables) { + const response = await delete_( + `${API_URL}/organizations/${slug}/members/invite?invited_id=${invitedId}`, + {} + ) + if (response.error) { + throw response.error + } + + return response +} + +type OrganizationMemberInviteDeleteData = Awaited> + +export const useOrganizationMemberInviteDeleteMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions< + OrganizationMemberInviteDeleteData, + unknown, + OrganizationMemberInviteDeleteVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + OrganizationMemberInviteDeleteData, + unknown, + OrganizationMemberInviteDeleteVariables + >((vars) => deleteOrganizationMemberInvite(vars), { + async onSuccess(data, variables, context) { + const { slug } = variables + + await Promise.all([ + queryClient.invalidateQueries(organizationKeys.detail(slug)), + queryClient.invalidateQueries(organizationKeys.roles(slug)), + ]) + + await onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/studio/data/organizations/organization-member-update-mutation.ts b/studio/data/organizations/organization-member-update-mutation.ts new file mode 100644 index 00000000000..bdfb211ebc7 --- /dev/null +++ b/studio/data/organizations/organization-member-update-mutation.ts @@ -0,0 +1,54 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { patch } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { organizationKeys } from './keys' + +export type OrganizationMemberUpdateVariables = { + slug: string + gotrueId: string + roleId: number +} + +export async function updateOrganizationMember({ + slug, + gotrueId, + roleId, +}: OrganizationMemberUpdateVariables) { + const response = await patch(`${API_URL}/organizations/${slug}/members/${gotrueId}`, { + role_id: roleId, + }) + if (response.error) { + throw response.error + } + + return response +} + +type OrganizationMemberUpdateData = Awaited> + +export const useOrganizationMemberUpdateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => updateOrganizationMember(vars), + { + async onSuccess(data, variables, context) { + const { slug } = variables + + await Promise.all([ + queryClient.invalidateQueries(organizationKeys.detail(slug)), + queryClient.invalidateQueries(organizationKeys.roles(slug)), + ]) + + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/data/organizations/organization-roles-query.ts b/studio/data/organizations/organization-roles-query.ts new file mode 100644 index 00000000000..67618853e85 --- /dev/null +++ b/studio/data/organizations/organization-roles-query.ts @@ -0,0 +1,63 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { Role } from 'types' +import { organizationKeys } from './keys' + +export type OrganizationRolesVariables = { + slug?: string +} + +export type OrganizationRolesResponse = { + roles: Role[] +} + +export async function getOrganizationRoles( + { slug }: OrganizationRolesVariables, + signal?: AbortSignal +) { + if (!slug) { + throw new Error('slug is required') + } + + const data = await get(`${API_URL}/organizations/${slug}/roles`, { + signal, + }) + if (data.error) { + throw data.error + } + + return { roles: data } as OrganizationRolesResponse +} + +export type OrganizationRolesData = Awaited> +export type OrganizationRolesError = unknown + +export const useOrganizationRolesQuery = ( + { slug }: OrganizationRolesVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + organizationKeys.roles(slug), + ({ signal }) => getOrganizationRoles({ slug }, signal), + { + enabled: enabled && typeof slug !== 'undefined', + ...options, + } + ) + +export const useOrganizationRolesPrefetch = ({ slug }: OrganizationRolesVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (slug) { + client.prefetchQuery(organizationKeys.roles(slug), ({ signal }) => + getOrganizationRoles({ slug }, signal) + ) + } + }, [slug]) +} diff --git a/studio/hooks/queries/index.ts b/studio/hooks/queries/index.ts index 18887b214f3..391035b5bac 100644 --- a/studio/hooks/queries/index.ts +++ b/studio/hooks/queries/index.ts @@ -1,6 +1,4 @@ export * from './useJwtSecretUpdateStatus' -export * from './useOrganizationDetail' -export * from './useOrganizationRoles' export * from './useFreeProjectLimitCheck' export * from './useProjectAuthConfig' export * from './useProjectSubscription' diff --git a/studio/hooks/queries/useOrganizationDetail.ts b/studio/hooks/queries/useOrganizationDetail.ts deleted file mode 100644 index 275d9397bf0..00000000000 --- a/studio/hooks/queries/useOrganizationDetail.ts +++ /dev/null @@ -1,59 +0,0 @@ -import useSWR, { mutate } from 'swr' - -import { Member } from 'types' -import { API_URL } from 'lib/constants' -import { get } from 'lib/common/fetch' -import { useFlag } from 'hooks' - -export function useOrganizationDetail(slug: string) { - const enablePermissions = useFlag('enablePermissions') - - // Get org members - const url = enablePermissions - ? `${API_URL}/organizations/${slug}/members?member_roles` - : `${API_URL}/props/org/${slug}` - - const { data, error } = useSWR(slug ? url : null, get) - let members = enablePermissions - ? data - : (data?.members ?? []).map((m: any) => ({ - id: m.id, - is_owner: m.is_owner, - username: m.profile.username, - primary_email: m.profile.primary_email, - })) ?? [] - - // Get pending invite users - const pendingInviteUrl = `${API_URL}/organizations/${slug}/members/invite` - const { data: inviteData, error: inviteError } = useSWR(slug ? pendingInviteUrl : null, get) - - if (data && inviteData && inviteData.length > 0) { - // Remap invite data to look like existing members data - const invitedMembers = inviteData.map((x: any) => { - const member = { - is_owner: false, - invited_at: x.invited_at, - invited_id: x.invited_id, - username: x.invited_email.slice(0, 1), - primary_email: x.invited_email, - } - return enablePermissions ? { ...member, role_ids: [x.role_id] } : member - }) - - members = [...members, ...invitedMembers] - } - - const anyError = data?.error || error || inviteError - - function mutateOrgMembers(updatedMembers: Member[], revalidate?: boolean) { - mutate(url, [...updatedMembers], revalidate ?? true) - mutate(pendingInviteUrl, {}, revalidate ?? true) - } - - return { - members: members as Member[], - isLoading: !anyError && !data, - isError: !!anyError, - mutateOrgMembers, - } -} diff --git a/studio/hooks/queries/useOrganizationRoles.ts b/studio/hooks/queries/useOrganizationRoles.ts deleted file mode 100644 index 8267ec0ad9f..00000000000 --- a/studio/hooks/queries/useOrganizationRoles.ts +++ /dev/null @@ -1,22 +0,0 @@ -import useSWR from 'swr' -import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' -import { Role } from 'types' - -// [Joshen] Putting roles in a hook for now for simplicity -// Unless we plan to support CRUD on roles i think it can stay as a hook -// otherwise, best to shift it to a store. If we do shift it, have it in the -// App store to prevent confusion with PG roles - -export function useOrganizationRoles(slug?: string) { - const url = `${API_URL}/organizations/${slug}/roles` - const { data, error } = useSWR(slug ? url : null, get) - const anyError = data?.error || error - - return { - roles: anyError ? [] : (data as Role[]), - error: anyError, - isLoading: !anyError && !data, - isError: !!anyError, - } -} diff --git a/studio/pages/org/[slug]/settings.tsx b/studio/pages/org/[slug]/settings.tsx index bf3c8afaf25..5e69011649c 100644 --- a/studio/pages/org/[slug]/settings.tsx +++ b/studio/pages/org/[slug]/settings.tsx @@ -4,14 +4,7 @@ import { useRouter } from 'next/router' import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' import { Member, NextPageWithLayout, Organization, Project, Role, User } from 'types' -import { - useFlag, - useOrganizationDetail, - useOrganizationRoles, - useStore, - withAuth, - useParams, -} from 'hooks' +import { useFlag, useStore, withAuth, useParams } from 'hooks' import { AccountLayoutWithoutAuth } from 'components/layouts' import { GeneralSettings, @@ -19,6 +12,8 @@ import { BillingSettings, InvoicesSettings, } from 'components/interfaces/Organization' +import { useOrganizationDetailQuery } from 'data/organizations/organization-detail-query' +import { useOrganizationRolesQuery } from 'data/organizations/organization-roles-query' export const PageContext = createContext(null) @@ -28,8 +23,9 @@ const OrgSettingsLayout = withAuth( const router = useRouter() const { slug } = useParams() - const { roles: allRoles } = useOrganizationRoles(slug) + const { data } = useOrganizationRolesQuery({ slug }) const enableBillingOnlyReadOnly = useFlag('enableBillingOnlyReadOnlyRoles') + const allRoles = data?.roles const roles = enableBillingOnlyReadOnly ? allRoles : (allRoles ?? []).filter((role) => @@ -114,7 +110,7 @@ const OrganizationSettings: NextPageWithLayout = () => { const PageState: any = useContext(PageContext) const { ui, app } = useStore() const [selectedTab, setSelectedTab] = useState('GENERAL') - const { members, isError: isOrgDetailError } = useOrganizationDetail(slug || '') + const { data, isError: isOrgDetailError } = useOrganizationDetailQuery({ slug }) const hash = router.asPath.split('#')[1]?.toUpperCase() useEffect(() => { @@ -144,9 +140,9 @@ const OrganizationSettings: NextPageWithLayout = () => { useEffect(() => { if (!isOrgDetailError) { - PageState.members = members ?? [] + PageState.members = data?.members ?? [] } - }, [members, isOrgDetailError]) + }, [data?.members, isOrgDetailError]) if (!ui.selectedOrganization || !PageState.organization) return
From 872112e3b6d5b28f5e2acd28ec9f795c490775e1 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Mon, 19 Dec 2022 12:22:16 +0000 Subject: [PATCH 07/83] adds available_in_plan to usage query --- studio/data/usage/project-usage-query.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/studio/data/usage/project-usage-query.ts b/studio/data/usage/project-usage-query.ts index b487e78039e..577fd0bfd69 100644 --- a/studio/data/usage/project-usage-query.ts +++ b/studio/data/usage/project-usage-query.ts @@ -13,12 +13,14 @@ export interface DbSize { limit: number cost: number current: number + available_in_plan: boolean } export interface DbEgress { usage: number limit: number cost: number + available_in_plan: boolean } export interface StorageSize { @@ -26,18 +28,21 @@ export interface StorageSize { limit: number cost: number current: number + available_in_plan: boolean } export interface StorageEgress { usage: number limit: number cost: number + available_in_plan: boolean } export interface MonthlyActiveUsers { usage: number limit: number cost: number + available_in_plan: boolean } export type ProjectUsageResponse = { From 09218cafea911456ae4a4242e120f7ecce6f4bd7 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Tue, 3 Jan 2023 19:48:58 +0000 Subject: [PATCH 08/83] move useAccessTokens to react-query --- .../interfaces/Account/AccessTokenList.tsx | 23 +++++----- .../Account/NewAccessTokenButton.tsx | 33 +++++++------ .../Functions/TerminalInstructions.tsx | 18 ++++---- .../access-tokens-create-mutation.ts | 44 ++++++++++++++++++ .../access-tokens-delete-mutation.ts | 41 +++++++++++++++++ .../data/access-tokens/access-tokens-query.ts | 46 +++++++++++++++++++ studio/data/access-tokens/keys.ts | 3 ++ studio/data/permissions/keys.ts | 2 +- studio/data/permissions/permissions-query.ts | 4 +- studio/hooks/queries/useAccessTokens.ts | 40 ---------------- 10 files changed, 175 insertions(+), 79 deletions(-) create mode 100644 studio/data/access-tokens/access-tokens-create-mutation.ts create mode 100644 studio/data/access-tokens/access-tokens-delete-mutation.ts create mode 100644 studio/data/access-tokens/access-tokens-query.ts create mode 100644 studio/data/access-tokens/keys.ts delete mode 100644 studio/hooks/queries/useAccessTokens.ts diff --git a/studio/components/interfaces/Account/AccessTokenList.tsx b/studio/components/interfaces/Account/AccessTokenList.tsx index 0e3aba63e4b..8abe747f6e2 100644 --- a/studio/components/interfaces/Account/AccessTokenList.tsx +++ b/studio/components/interfaces/Account/AccessTokenList.tsx @@ -1,9 +1,8 @@ import { useState } from 'react' import { Button, Modal, IconTrash } from 'ui' import { useStore } from 'hooks' -import { delete_ } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' -import { AccessToken, useAccessTokens } from 'hooks/queries/useAccessTokens' +import { AccessToken, useAccessTokensQuery } from 'data/access-tokens/access-tokens-query' +import { useAccessTokenDeleteMutation } from 'data/access-tokens/access-tokens-delete-mutation' import { observer } from 'mobx-react-lite' import Table from 'components/to-be-cleaned/Table' @@ -11,21 +10,21 @@ import ConfirmationModal from 'components/ui/ConfirmationModal' const AccessTokenList = observer(() => { const { ui } = useStore() - const { mutateDeleteToken } = useAccessTokens() - const { tokens, isLoading } = useAccessTokens() + const { data: tokens, isLoading } = useAccessTokensQuery() + const { mutateAsync: deleteToken } = useAccessTokenDeleteMutation() + const [isOpen, setIsOpen] = useState(false) const [token, setToken] = useState(undefined) async function onDeleteToken(tokenId: number) { - const response = await delete_(`${API_URL}/profile/access-tokens/${tokenId}`) - if (response.error) { + try { + await deleteToken({ id: tokenId }) + setIsOpen(false) + } catch (error: any) { ui.setNotification({ category: 'error', - message: `Failed to delete token: ${response.error.message}`, + message: `Failed to delete token: ${error.message}`, }) - } else { - mutateDeleteToken(tokenId) - setIsOpen(false) } } @@ -50,7 +49,7 @@ const AccessTokenList = observer(() => { ) : ( <> - {tokens?.map((x: AccessToken) => { + {tokens?.map((x) => { return ( diff --git a/studio/components/interfaces/Account/NewAccessTokenButton.tsx b/studio/components/interfaces/Account/NewAccessTokenButton.tsx index edf5e24de1d..4bdf9e36fa6 100644 --- a/studio/components/interfaces/Account/NewAccessTokenButton.tsx +++ b/studio/components/interfaces/Account/NewAccessTokenButton.tsx @@ -1,14 +1,14 @@ import { FC, useState } from 'react' import { Input, Button, Modal, Form, Alert } from 'ui' import { useStore } from 'hooks' -import { post } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' -import { NewAccessToken, useAccessTokens } from 'hooks/queries/useAccessTokens' +import { + NewAccessToken, + useAccessTokenCreateMutation, +} from 'data/access-tokens/access-tokens-create-mutation' import { observer } from 'mobx-react-lite' const NewAccessTokenButton = observer(() => { const { ui } = useStore() - const { mutateNewToken } = useAccessTokens() const [isOpen, setIsOpen] = useState(false) const [newToken, setNewToken] = useState(undefined) @@ -20,21 +20,24 @@ const NewAccessTokenButton = observer(() => { return errors } + const { mutateAsync: createAccessToken } = useAccessTokenCreateMutation() + async function onFormSubmit(values: any, { setSubmitting }: any) { setSubmitting(true) - const response = await post(`${API_URL}/profile/access-tokens`, { name: values.tokenName }) - if (response.error) { - ui.setNotification({ - category: 'error', - message: `Failed to create token: ${response.error.message}`, - }) - setSubmitting(false) - } else { - mutateNewToken(response) + + try { + const response = await createAccessToken({ name: values.tokenName }) setNewToken(response) setSubmitting(false) setIsOpen(false) + } catch (error: any) { + ui.setNotification({ + category: 'error', + message: `Failed to create token: ${error.message}`, + }) + + setSubmitting(false) } } @@ -68,7 +71,7 @@ const NewAccessTokenButton = observer(() => { validate={validate} > {({ isSubmitting }: { isSubmitting: boolean }) => ( -
+
= observer(({ data }) => { copy readOnly size="small" - className="input-mono max-w-xl" + className="max-w-xl input-mono" value={data.token} onChange={() => {}} /> diff --git a/studio/components/interfaces/Functions/TerminalInstructions.tsx b/studio/components/interfaces/Functions/TerminalInstructions.tsx index 584bac12268..db17da57b61 100644 --- a/studio/components/interfaces/Functions/TerminalInstructions.tsx +++ b/studio/components/interfaces/Functions/TerminalInstructions.tsx @@ -1,7 +1,7 @@ import CommandRender from 'components/interfaces/Functions/CommandRender' +import { useAccessTokensQuery } from 'data/access-tokens/access-tokens-query' import { useProjectApiQuery } from 'data/config/project-api-query' import { useParams } from 'hooks' -import { useAccessTokens } from 'hooks/queries/useAccessTokens' import Link from 'next/link' import { useRouter } from 'next/router' import { FC, useState } from 'react' @@ -18,7 +18,7 @@ const TerminalInstructions: FC = ({ closable = false }) => { const [showInstructions, setShowInstructions] = useState(!closable) - const { tokens } = useAccessTokens() + const { data: tokens } = useAccessTokensQuery() const { data: settings } = useProjectApiQuery({ projectRef, @@ -35,8 +35,8 @@ const TerminalInstructions: FC = ({ closable = false }) => { ].join('.') // get the .co or .net TLD from the restUrl - const restUrl = settings?.autoApiService.restUrl ?? '' - const restUrlTld = new URL(restUrl).hostname.split('.').pop() + const restUrl = settings?.autoApiService.restUrl + const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : '' const commands: Commands[] = [ { @@ -84,13 +84,13 @@ const TerminalInstructions: FC = ({ closable = false }) => { return (
-
+
-
+

Terminal instructions

@@ -110,7 +110,7 @@ const TerminalInstructions: FC = ({ closable = false }) => {
{tokens && tokens.length === 0 ? ( -
+

You may need to create an access token

@@ -122,7 +122,7 @@ const TerminalInstructions: FC = ({ closable = false }) => {

) : ( -
+

Need help?

diff --git a/studio/data/access-tokens/access-tokens-create-mutation.ts b/studio/data/access-tokens/access-tokens-create-mutation.ts new file mode 100644 index 00000000000..8e13d2fe1fe --- /dev/null +++ b/studio/data/access-tokens/access-tokens-create-mutation.ts @@ -0,0 +1,44 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { AccessToken } from './access-tokens-query' +import { accessTokenKeys } from './keys' + +export type AccessTokenCreateVariables = { + name: string +} + +export type NewAccessToken = AccessToken & { token: string } + +export async function createAccessToken({ name }: AccessTokenCreateVariables) { + const response = await post(`${API_URL}/profile/access-tokens`, { name }) + if (response.error) { + throw response.error + } + + return response as NewAccessToken +} + +type AccessTokenCreateData = Awaited> + +export const useAccessTokenCreateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => createAccessToken(vars), + { + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries(accessTokenKeys.list()) + + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/data/access-tokens/access-tokens-delete-mutation.ts b/studio/data/access-tokens/access-tokens-delete-mutation.ts new file mode 100644 index 00000000000..0f9bc82f575 --- /dev/null +++ b/studio/data/access-tokens/access-tokens-delete-mutation.ts @@ -0,0 +1,41 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { delete_ } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { accessTokenKeys } from './keys' + +export type AccessTokenDeleteVariables = { + id: number +} + +export async function deleteAccessToken({ id }: AccessTokenDeleteVariables) { + const response = await delete_(`${API_URL}/profile/access-tokens/${id}`) + if (response.error) { + throw response.error + } + + return response +} + +type AccessTokenDeleteData = Awaited> + +export const useAccessTokenDeleteMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => deleteAccessToken(vars), + { + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries(accessTokenKeys.list()) + + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/data/access-tokens/access-tokens-query.ts b/studio/data/access-tokens/access-tokens-query.ts new file mode 100644 index 00000000000..6cfb187510e --- /dev/null +++ b/studio/data/access-tokens/access-tokens-query.ts @@ -0,0 +1,46 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { accessTokenKeys } from './keys' + +export type AccessToken = { + id: number + token_alias: string + name: string + created_at: number +} + +export type AccessTokensResponse = AccessToken[] + +export async function getAccessTokens(signal?: AbortSignal) { + const response = await get(`${API_URL}/profile/access-tokens`, { + signal, + }) + if (response.error) { + throw response.error + } + + return response as AccessTokensResponse +} + +export type AccessTokensData = Awaited> +export type AccessTokensError = unknown + +export const useAccessTokensQuery = ({ + enabled = true, + ...options +}: UseQueryOptions = {}) => + useQuery( + accessTokenKeys.list(), + ({ signal }) => getAccessTokens(signal), + options + ) + +export const useAccessTokensPrefetch = () => { + const client = useQueryClient() + + return useCallback(() => { + client.prefetchQuery(accessTokenKeys.list(), ({ signal }) => getAccessTokens(signal)) + }, []) +} diff --git a/studio/data/access-tokens/keys.ts b/studio/data/access-tokens/keys.ts new file mode 100644 index 00000000000..8d5e3a8f3da --- /dev/null +++ b/studio/data/access-tokens/keys.ts @@ -0,0 +1,3 @@ +export const accessTokenKeys = { + list: () => ['access-tokens'] as const, +} diff --git a/studio/data/permissions/keys.ts b/studio/data/permissions/keys.ts index c2598a44758..f90354c2b84 100644 --- a/studio/data/permissions/keys.ts +++ b/studio/data/permissions/keys.ts @@ -1,3 +1,3 @@ export const permissionKeys = { - permissions: () => ['permissions'] as const, + list: () => ['permissions'] as const, } diff --git a/studio/data/permissions/permissions-query.ts b/studio/data/permissions/permissions-query.ts index 9f5367e47ec..45d73e0d0a5 100644 --- a/studio/data/permissions/permissions-query.ts +++ b/studio/data/permissions/permissions-query.ts @@ -26,7 +26,7 @@ export const usePermissionsQuery = ({ ...options }: UseQueryOptions = {}) => useQuery( - permissionKeys.permissions(), + permissionKeys.list(), ({ signal }) => getPermissions(signal), options ) @@ -35,6 +35,6 @@ export const usePermissionsPrefetch = () => { const client = useQueryClient() return useCallback(() => { - client.prefetchQuery(permissionKeys.permissions(), ({ signal }) => getPermissions(signal)) + client.prefetchQuery(permissionKeys.list(), ({ signal }) => getPermissions(signal)) }, []) } diff --git a/studio/hooks/queries/useAccessTokens.ts b/studio/hooks/queries/useAccessTokens.ts deleted file mode 100644 index 918c827fbad..00000000000 --- a/studio/hooks/queries/useAccessTokens.ts +++ /dev/null @@ -1,40 +0,0 @@ -import useSWR, { mutate } from 'swr' -import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' - -export interface AccessToken { - id: number - token_alias: string - name: string - created_at: number -} - -export interface NewAccessToken extends AccessToken { - token: string -} - -export function useAccessTokens() { - const url = `${API_URL}/profile/access-tokens` - let { data, error } = useSWR(url, get) - const anyError = data?.error || error - - function mutateNewToken(newToken: AccessToken, revalidate?: boolean) { - mutate(url, (data: AccessToken[]) => [...(data || []), newToken], revalidate ?? true) - } - - function mutateDeleteToken(tokenId: number, revalidate?: boolean) { - mutate( - url, - (data: AccessToken[]) => data.filter((x: AccessToken) => x.id === tokenId), - revalidate ?? true - ) - } - - return { - tokens: anyError ? undefined : (data as AccessToken[]), - isLoading: !anyError && !data, - isError: !!anyError, - mutateNewToken, - mutateDeleteToken, - } -} From a79e0812726870dace3fade885b1700bdaddee12 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Tue, 3 Jan 2023 20:42:54 +0000 Subject: [PATCH 09/83] move useFreeProjectLimitCheck to react-query --- .../ProjectLayout/ProjectPausedState.tsx | 11 +-- .../free-project-limit-check-query.ts | 69 +++++++++++++++++++ studio/data/organizations/keys.ts | 2 + studio/hooks/queries/index.ts | 1 - .../hooks/queries/useFreeProjectLimitCheck.ts | 22 ------ studio/pages/new/[slug].tsx | 17 ++--- .../[ref]/settings/billing/update/index.tsx | 13 ++-- 7 files changed, 93 insertions(+), 42 deletions(-) create mode 100644 studio/data/organizations/free-project-limit-check-query.ts delete mode 100644 studio/hooks/queries/useFreeProjectLimitCheck.ts diff --git a/studio/components/layouts/ProjectLayout/ProjectPausedState.tsx b/studio/components/layouts/ProjectLayout/ProjectPausedState.tsx index 1392bbd62e9..8181045826d 100644 --- a/studio/components/layouts/ProjectLayout/ProjectPausedState.tsx +++ b/studio/components/layouts/ProjectLayout/ProjectPausedState.tsx @@ -4,11 +4,12 @@ import { Modal, Button, IconPauseCircle } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' import { Project } from 'types' -import { checkPermissions, useStore, useFlag, useFreeProjectLimitCheck } from 'hooks' +import { checkPermissions, useStore, useFlag } from 'hooks' import { post } from 'lib/common/fetch' import { API_URL, PROJECT_STATUS } from 'lib/constants' import { DeleteProjectButton } from 'components/interfaces/Settings/General' import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog' +import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' interface Props { project: Project @@ -19,7 +20,7 @@ const ProjectPausedState: FC = ({ project }) => { const orgSlug = ui.selectedOrganization?.slug const kpsEnabled = useFlag('initWithKps') - const { membersExceededLimit } = useFreeProjectLimitCheck(orgSlug) + const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery({ slug: orgSlug }) const hasMembersExceedingFreeTierLimit = (membersExceededLimit || []).length > 0 const [showConfirmRestore, setShowConfirmRestore] = useState(false) @@ -49,7 +50,7 @@ const ProjectPausedState: FC = ({ project }) => { return ( <>

-
+
@@ -112,14 +113,14 @@ const ProjectPausedState: FC = ({ project }) => { header="Your organization has members who have exceeded their free project limits" onCancel={() => setShowFreeProjectLimitWarning(false)} > -
+

The following members have reached their maximum limits for the number of active free tier projects within organizations where they are an administrator or owner:

-
    +
      {(membersExceededLimit || []).map((member, idx: number) => (
    • {member.username || member.primary_email} (Limit: {member.free_project_limit}{' '} diff --git a/studio/data/organizations/free-project-limit-check-query.ts b/studio/data/organizations/free-project-limit-check-query.ts new file mode 100644 index 00000000000..182e38ab065 --- /dev/null +++ b/studio/data/organizations/free-project-limit-check-query.ts @@ -0,0 +1,69 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { organizationKeys } from './keys' + +export type MemberWithFreeProjectLimit = { + free_project_limit: number + primary_email: string + username: string +} + +export type FreeProjectLimitCheckVariables = { + slug?: string +} + +export type FreeProjectLimitCheckResponse = MemberWithFreeProjectLimit[] + +export async function getFreeProjectLimitCheck( + { slug }: FreeProjectLimitCheckVariables, + signal?: AbortSignal +) { + if (!slug) { + throw new Error('slug is required') + } + + const response = await get( + `${API_URL}/organizations/${slug}/members/reached-free-project-limit`, + { + signal, + } + ) + if (response.error) { + throw response.error + } + + return response as FreeProjectLimitCheckResponse +} + +export type FreeProjectLimitCheckData = Awaited> +export type FreeProjectLimitCheckError = unknown + +export const useFreeProjectLimitCheckQuery = ( + { slug }: FreeProjectLimitCheckVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + organizationKeys.freeProjectLimitCheck(slug), + ({ signal }) => getFreeProjectLimitCheck({ slug }, signal), + { + enabled: enabled && typeof slug !== 'undefined', + ...options, + } + ) + +export const useFreeProjectLimitCheckPrefetch = ({ slug }: FreeProjectLimitCheckVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (slug) { + client.prefetchQuery(organizationKeys.freeProjectLimitCheck(slug), ({ signal }) => + getFreeProjectLimitCheck({ slug }, signal) + ) + } + }, [slug]) +} diff --git a/studio/data/organizations/keys.ts b/studio/data/organizations/keys.ts index 11952afb649..5b7250c028d 100644 --- a/studio/data/organizations/keys.ts +++ b/studio/data/organizations/keys.ts @@ -1,4 +1,6 @@ export const organizationKeys = { detail: (slug: string | undefined) => ['organizations', slug, 'detail'] as const, roles: (slug: string | undefined) => ['organizations', slug, 'roles'] as const, + freeProjectLimitCheck: (slug: string | undefined) => + ['organizations', slug, 'free-project-limit-check'] as const, } diff --git a/studio/hooks/queries/index.ts b/studio/hooks/queries/index.ts index 391035b5bac..14a5c4db7f1 100644 --- a/studio/hooks/queries/index.ts +++ b/studio/hooks/queries/index.ts @@ -1,5 +1,4 @@ export * from './useJwtSecretUpdateStatus' -export * from './useFreeProjectLimitCheck' export * from './useProjectAuthConfig' export * from './useProjectSubscription' export * from './useSubscriptionStats' diff --git a/studio/hooks/queries/useFreeProjectLimitCheck.ts b/studio/hooks/queries/useFreeProjectLimitCheck.ts deleted file mode 100644 index 3884388ebdd..00000000000 --- a/studio/hooks/queries/useFreeProjectLimitCheck.ts +++ /dev/null @@ -1,22 +0,0 @@ -import useSWR from 'swr' -import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' - -export interface MemberWithFreeProjectLimit { - free_project_limit: number - primary_email: string - username: string -} - -export function useFreeProjectLimitCheck(slug?: string) { - const url = `${API_URL}/organizations/${slug}/members/reached-free-project-limit` - const { data, error } = useSWR(slug ? url : null, get) - const anyError = data?.error || error - - return { - membersExceededLimit: anyError ? undefined : (data as MemberWithFreeProjectLimit[]), - error: anyError, - isLoading: !anyError && !data, - isError: !!anyError, - } -} diff --git a/studio/pages/new/[slug].tsx b/studio/pages/new/[slug].tsx index 0578455f4f4..64bf710eff1 100644 --- a/studio/pages/new/[slug].tsx +++ b/studio/pages/new/[slug].tsx @@ -27,8 +27,9 @@ import { withAuth, useSubscriptionStats, checkPermissions, - useFreeProjectLimitCheck, + useParams, } from 'hooks' +import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' import { WizardLayoutWithoutAuth } from 'components/layouts' import Panel from 'components/ui/Panel' @@ -42,14 +43,14 @@ import { const Wizard: NextPageWithLayout = () => { const router = useRouter() - const { slug } = router.query + const { slug } = useParams() const { app, ui } = useStore() const projectCreationDisabled = useFlag('disableProjectCreationAndUpdate') const kpsEnabled = useFlag('initWithKps') const subscriptionStats = useSubscriptionStats() - const { membersExceededLimit, isLoading: isLoadingFreeProjectLimitCheck } = - useFreeProjectLimitCheck(slug as string) + const { data: membersExceededLimit, isLoading: isLoadingFreeProjectLimitCheck } = + useFreeProjectLimitCheckQuery({ slug }) const [projectName, setProjectName] = useState('') const [dbPass, setDbPass] = useState('') @@ -213,7 +214,7 @@ const Wizard: NextPageWithLayout = () => {
} footer={ -
+
@@ -242,7 +243,7 @@ const Wizard: NextPageWithLayout = () => {

{projectCreationDisabled ? ( - + ) : ( @@ -296,7 +297,7 @@ const Wizard: NextPageWithLayout = () => { /> - + 0} @@ -318,7 +319,7 @@ const Wizard: NextPageWithLayout = () => { /> - + { const orgSlug = ui.selectedOrganization?.slug const projectRef = ui.selectedProject?.ref - const { membersExceededLimit } = useFreeProjectLimitCheck(orgSlug as string) + const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery({ slug: orgSlug }) const hasMembersExceedingFreeTierLimit = (membersExceededLimit || []).length > 0 const [isLoadingProducts, setIsLoadingProducts] = useState(false) @@ -103,7 +104,7 @@ const BillingUpdate: NextPageWithLayout = () => { if (isLoadingProducts || isEnterprise) { return ( -
+
) @@ -111,7 +112,7 @@ const BillingUpdate: NextPageWithLayout = () => { return ( <> -
+
{ header="Your organization has members who have exceeded their free project limits" onCancel={() => setShowDowngradeError(false)} > -
+

The following members have reached their maximum limits for the number of active free tier projects within organizations where they are an administrator or owner:

-